From 636cf52d5eec3af46ad1ed146e7711be7542a06e Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Thu, 27 Nov 2025 15:16:33 +0100 Subject: [PATCH 01/15] first version of distribution factor analysis toolkit. --- pandapower/analysis/LODF.py | 505 ++++++++++++++++++ pandapower/analysis/PSDF.py | 229 ++++++++ pandapower/analysis/PTDF.py | 421 +++++++++++++++ pandapower/analysis/__init__.py | 0 pandapower/analysis/sensitivity_dc.py | 270 ++++++++++ pandapower/analysis/utils.py | 195 +++++++ pandapower/test/analysis/__init__.py | 0 .../analysis/test_distribution_factors.py | 132 +++++ 8 files changed, 1752 insertions(+) create mode 100644 pandapower/analysis/LODF.py create mode 100644 pandapower/analysis/PSDF.py create mode 100644 pandapower/analysis/PTDF.py create mode 100644 pandapower/analysis/__init__.py create mode 100644 pandapower/analysis/sensitivity_dc.py create mode 100644 pandapower/analysis/utils.py create mode 100644 pandapower/test/analysis/__init__.py create mode 100644 pandapower/test/analysis/test_distribution_factors.py diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py new file mode 100644 index 0000000000..29e4b39711 --- /dev/null +++ b/pandapower/analysis/LODF.py @@ -0,0 +1,505 @@ +from typing import Union, List, Dict, Tuple +from copy import deepcopy +from itertools import product + +import pandas as pd +import numpy as np + +from pandapower import pandapowerNet +from pandapower.analysis.PTDF import _makePTDF_ppci, _get_PTDF_perturb +from pandapower.analysis.sensitivity_dc import run_dc_n1 +from pandapower.analysis.utils import _get_branch_lookup, _get_trafo3w_lookup, \ + branch_dict_to_ppci_branch_list, _get_outage_branch_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, \ + BR_SIDE_MAPPING_1, BR_PTDF_MAPPING, BR_PTDF_MAPPING_1, BR_NAN_CHECK, ELE_IX_TYPE +from pandapower.run import rundcpp +from pandapower.create import create_load, create_ext_grid +from pandapower.pypower.makeLODF import makeLODF + +import logging +logger = logging.getLogger(__name__) + + +def _get_LODF_direct( + net, + outage_branch_type, + outage_branch_ix=None, + using_sparse_solver=True, + random_verify=True, + branch_dict=None, + reduced=True, +): + """ + this function calculate LODF (ratio without unit) of a pp branch from the outage of a pp branch + with pypower matrix function. + """ + if net.bus.shape[0] > 3000 and not using_sparse_solver: + logger.warning("Calculating lodf for large network, switched to sparse solver!") + + # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup + branch_ppci_lookup = None + branch_id = None + if branch_dict is not None: + branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) + else: + reduced = False + + ptdf_ppci, ppci = _makePTDF_ppci( + net, using_sparse_solver=using_sparse_solver, result_side=0, branch_id=branch_id, reduced=reduced + ) + + # Set super small value to 0 for better numerical stability + ptdf_ppci[np.isclose(ptdf_ppci, 0, atol=1e-10)] = 0 + # Identify bridge branches not allowed to outage + bridge_branch_mask = np.any(np.isclose(np.abs(ptdf_ppci), 1, atol=1e-8), axis=1) | np.all( + np.isclose(np.nan_to_num(ptdf_ppci), 0, atol=1e-8), axis=1 + ) + + # Create lodf ppci with ptdf ppci + if reduced: + lodf_ppci = makeLODF(ppci["branch"][branch_id], ptdf_ppci) + else: + lodf_ppci = makeLODF(ppci["branch"], ptdf_ppci) + + # Set results to default value of bridge branch + lodf_ppci[:, bridge_branch_mask] = np.NaN + if branch_id is not None and not reduced: + branch_id_complement = [x for x in range(list(branch_ppci_lookup.values())[-1][1]) if x not in branch_id] + lodf_ppci[:, branch_id_complement] = np.NaN + lodf_ppci[branch_id_complement, :] = np.NaN + + # Checkout ppci lodf to pp level + if reduced: + lodf_pp_np = _LODF_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=branch_ppci_lookup) + else: + lodf_pp_np = _LODF_ppci_to_pp(net, lodf_ppci) + + # lodf pp contains all data + # Convert numpy array to pandas dataframe with the pandapower element index + if reduced: + lodf = _LODF_pp_np_to_df(net, lodf_pp_np, branch_dict=branch_dict) + else: + lodf = _LODF_pp_np_to_df(net, lodf_pp_np) + + # Select only required data points according to the outage_branch_type + if outage_branch_type is not None: + outage_branch_ix = _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix) + if reduced: + lodf = {key: value for key, value in lodf.items() if key[1] == outage_branch_type} + else: + lodf = {key: value.loc[:, outage_branch_ix] for key, value in lodf.items() if key[1] == outage_branch_type} + + # Verify with random selection of branches + if random_verify and outage_branch_ix.size >= 3: + # Skip test if too few elements are calculated + # Select three random branches and verify against perturb method + verify_branch_ix = np.random.choice(outage_branch_ix, 3) + verify_LODF( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=verify_branch_ix, + lodf={key: data.loc[:, verify_branch_ix] for key, data in lodf.items()}, + ) + return lodf + + +def _init_LODF_pp_np(net, outage_branch_type, num_outage_branch): + lodf_pp = {} + for br_type in ("line", "dcline", "trafo", "impedance"): + if not net[br_type].empty: + lodf_pp[(br_type, outage_branch_type)] = np.zeros((net[br_type].shape[0], num_outage_branch), dtype=float) + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + lodf_pp[("trafo3w_" + side, outage_branch_type)] = np.zeros( + (net.trafo3w.shape[0], num_outage_branch), dtype=float + ) + + for data in lodf_pp.values(): + data[:] = np.NaN + return lodf_pp + + +def _LODF_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=None): + # convert the branch sensitivity of the ppci layer to pandapower net layer + if branch_ppci_lookup is not None: + pp_ppci_branch_lookups = { + br_type: range(branch_ppci_lookup[br_type][0], branch_ppci_lookup[br_type][1]) + for br_type in ("line", "trafo", "impedance") + if br_type in branch_ppci_lookup.keys() + } + pp_ppci_trafo3w_lookups = { + type: range(branch_ppci_lookup[type][0], branch_ppci_lookup[type][1]) + for type in ("trafo3w_hv", "trafo3w_mv", "trafo3w_lv") + if type in branch_ppci_lookup.keys() + } + else: + pp_ppci_branch_lookups = { + br_type: _get_branch_lookup(net, br_type) for br_type in ("line", "trafo", "impedance") + } + pp_ppci_trafo3w_lookups = _get_trafo3w_lookup(net) + + lodf_ppci_padding = np.pad(lodf_ppci, ((0, 1), (0, 1)), mode="constant", constant_values=DISCONNECTED_PADDING_VALUE) + + results = dict() + available_branch_types = [br_type for br_type, lookup in pp_ppci_branch_lookups.items() if lookup is not None] + + for goal_br_type, source_br_type in product(available_branch_types, repeat=2): + results[(goal_br_type, source_br_type)] = lodf_ppci_padding[pp_ppci_branch_lookups[goal_br_type], :][ + :, pp_ppci_branch_lookups[source_br_type] + ] + + if pp_ppci_trafo3w_lookups is not None: + # goal_trafo3w_side: ("trafo3w_hv", "trafo3w_mv", "trafo3w_lv") + for source_br_type in available_branch_types: + for goal_trafo3w_side in pp_ppci_trafo3w_lookups.keys(): + results[(goal_trafo3w_side, source_br_type)] = lodf_ppci_padding[ + pp_ppci_trafo3w_lookups[goal_trafo3w_side], : + ][:, pp_ppci_branch_lookups[source_br_type]] + return results + + +def _LODF_pp_np_to_df(net, res_pp_np, outage_branch_type=None, outage_branch_ix=None, branch_dict=None): + res = {} + for key, data in res_pp_np.items(): + data = res_pp_np[key] + + # Avoid inf + data[np.isinf(data)] = np.NaN + # Find "columns" contains only NaN + # ATTENTION: following two lines need to be commented out to neglect LODF of isolated lines + # only_nan_mask = np.all(np.isnan(data), axis=0) + # data[:, ~only_nan_mask] = np.nan_to_num(data[:, ~only_nan_mask]) + + goal_element, source_element = key + if outage_branch_type is not None: + if source_element != outage_branch_type: + # Skip unrequired data point + continue + + if outage_branch_ix is None: + outage_branch_ix = net[source_element].index.to_numpy() + else: + outage_branch_ix = net[source_element].index.to_numpy() + + goal_br_type = "trafo3w" if goal_element.startswith("trafo3w") else goal_element + if branch_dict is not None: + res[key] = pd.DataFrame(data=data, index=branch_dict[goal_br_type], columns=branch_dict[source_element]) + else: + res[key] = pd.DataFrame(data=data, index=net[goal_br_type].index.to_numpy(), columns=outage_branch_ix) + return res + + +def _get_LODF_perturb( + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE = None, + distributed_slack=True, + recycle="lodf", +) -> Dict[Tuple[str, str], pd.DataFrame]: + """ + this function calculate LODF (ratio without unit) of a pp branch from the outage of a pp branch + with perturb method (brute-force) + """ + # No side selection needed + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING + net_mod = deepcopy(net) + + # Using net_mod for outage branch, net for original value + rundcpp(net_mod, distributed_slack=distributed_slack, recycle=recycle) + net_mod_n1 = deepcopy(net_mod) + + outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) + + # Init lodf array, Using Numpy array for better performance + lodf_pp_np = _init_LODF_pp_np(net_mod, outage_branch_type, outage_branch_ix.shape[0]) + + outage_res_table, outage_res_type = ( + "res_" + outage_branch_type, + "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw", + ) + + # number of out of service buses + num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) + # list with the indices of out of service buses + # list_out_of_service_bus = list(net_mod.res_bus.va_degree[np.isnan(net_mod.res_bus.va_degree.to_numpy())].index) + + outage_p0_series = net_mod[outage_res_table][outage_res_type].copy() + + for ix, br_ix in enumerate(outage_branch_ix): + if net[outage_branch_type].at[br_ix, "in_service"] == False: + # Skip out of service branch + continue + else: + if np.isclose(outage_p0_series.at[br_ix], 0, atol=1e-6) & (outage_branch_type != "dcline"): + bus_0 = net_mod[outage_branch_type].at[br_ix, BR_SIDE_MAPPING[outage_branch_type] + "_bus"] + bus_1 = net_mod[outage_branch_type].at[br_ix, BR_SIDE_MAPPING_1[outage_branch_type] + "_bus"] + + # If the branch flow close to zero + # Fix low loading branch with ptdf + this_ptdf = _get_PTDF_perturb( + net_mod, source_bus=[bus_0, bus_1], distributed_slack=distributed_slack + ) # distributed slack is default True + if np.abs(this_ptdf[outage_branch_type + BR_PTDF_MAPPING[outage_branch_type]].at[br_ix, bus_0]) > 0.1: + bus_to_add_load = bus_0 + elif ( + np.abs(this_ptdf[outage_branch_type + BR_PTDF_MAPPING_1[outage_branch_type]].at[br_ix, bus_1]) > 0.1 + ): + bus_to_add_load = bus_1 + else: + bus_to_add_load = None + + if bus_to_add_load is not None: + create_load(net_mod, bus=bus_to_add_load, p_mw=-1) + create_load(net_mod_n1, bus=bus_to_add_load, p_mw=-1) + logger.info("Added load on %s to fix low loading branch" % str(br_ix)) + else: + logger.warning("Add load not possible on %s to fix low loading branch" % str(br_ix)) + continue + + # Update branch p0 in n-0 net + rundcpp(net_mod, distributed_slack=distributed_slack, recycle=recycle) + outage_p0_series = net_mod[outage_res_table][outage_res_type].copy() + + elif np.isclose(outage_p0_series.at[br_ix], 0, atol=1e-6) & (outage_branch_type == "dcline"): + bus_0 = net_mod[outage_branch_type].at[br_ix, BR_SIDE_MAPPING[outage_branch_type] + "_bus"] + bus_1 = net_mod[outage_branch_type].at[br_ix, BR_SIDE_MAPPING_1[outage_branch_type] + "_bus"] + + ## ext_grid auf offshore seite + # res_dcline -> spannung am offshore knoten -> wenn ja dann slack schon da, wenn nan dann keine rechnung/konvergenz offshore ----> slack + if net_mod[outage_res_table].loc[br_ix].vm_from_pu == 0: + bus_to_add_ext_grid = bus_0 + elif net_mod[outage_res_table].loc[br_ix].vm_to_pu == 0: + bus_to_add_ext_grid = bus_1 + else: + bus_to_add_ext_grid = None + + # add ext_grid to bus + create_ext_grid(net_mod, bus_to_add_ext_grid) + + # Update branch p0 in n-0 net + rundcpp(net_mod, distributed_slack=distributed_slack, recycle=recycle) + outage_p0_series = net_mod[outage_res_table][outage_res_type].copy() + + net_mod_n1[outage_branch_type].at[br_ix, "in_service"] = False + rundcpp(net_mod_n1, outage_branch_type=outage_branch_type, outage_branch_ix=br_ix, distributed_slack=distributed_slack, recycle=recycle) + net_mod_n1[outage_branch_type].at[br_ix, "in_service"] = True + + if np.sum(np.isnan(net_mod_n1.res_bus.va_degree.to_numpy())) > num_out_of_service_bus: + # outage of the considered line is resulting in islanding of the network + logger.warning(f"Outage of line {br_ix} is causing isolated nodes!") + + # calculate the LODF factor + for br_type in ("line", "dcline", "trafo", "impedance"): + br_res_table, br_res_type, br_res_nan = ( + "res_" + br_type, + "p_" + THIS_RES_BR_SIDE_MAPPING[br_type] + "_mw", + BR_NAN_CHECK[br_type], + ) + if not net[br_type].empty: + lodf_pp_np[(br_type, outage_branch_type)][:, ix] = ( + net_mod_n1[br_res_table][br_res_type].to_numpy() - net_mod[br_res_table][br_res_type].to_numpy() + ) / outage_p0_series.at[br_ix] + # replace LODF factors with NaN values, + # if the considered net element has NaN values in predefined columns + lodf_pp_np[(br_type, outage_branch_type)][net_mod_n1[br_res_table][br_res_nan].isna(), ix] = np.nan + + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + br_res_type = "p_" + side + "_mw" + lodf_pp_np[("trafo3w_" + side, outage_branch_type)][:, ix] = ( + net_mod_n1["res_trafo3w"][br_res_type].to_numpy() + - net_mod["res_trafo3w"][br_res_type].to_numpy() + ) / outage_p0_series.at[br_ix] + # replace LODF factors with NaN values, + # if the considered net element has NaN values in predefined columns + lodf_pp_np[("trafo3w_" + side, outage_branch_type)][ + net_mod_n1["res_trafo3w"][br_res_type].isna(), ix + ] = np.nan + + # lodf pp contains only a subset + lodf = _LODF_pp_np_to_df(net, lodf_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) + return lodf + + +# Example application function with LODF +def _get_dc_n1_with_LODF(net, outage_branch_type, outage_branch_ix=None, result_side=0, lodf=None): + """ + this function calculate p_mw of a side of branch under the outage + of another branch with LODF method + """ + + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + + if lodf is None: + lodf = _get_LODF_direct(net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) + + outage_branch_ix = _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix) + + res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) + + rundcpp(net, distributed_slack=True) + outage_br_p0_series = net["res_" + outage_branch_type][ + "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" + ].copy() + for ix, br_ix in enumerate(outage_branch_ix): + for br_type in ("line", "trafo", "impedance"): + if not net[br_type].empty: + this_lodf = lodf[(br_type, outage_branch_type)].loc[:, br_ix].to_numpy() + # Skip invalid branch and branch with zero flow + if np.all(np.isnan(this_lodf)) or np.isclose(outage_br_p0_series.at[br_ix], 0, atol=1e-6): + logger.info(f"""{outage_branch_type}: {ix} skipped! + p_mw: {np.abs(outage_br_p0_series.at[br_ix]):.2f}""") + continue + res_n1_pp_np[(br_type, outage_branch_type)][:, ix] = ( + net["res_" + br_type]["p_" + THIS_RES_BR_SIDE_MAPPING[br_type] + "_mw"] + + this_lodf * outage_br_p0_series.at[br_ix] + ) + + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + this_lodf = lodf[("trafo3w_" + side, outage_branch_type)].loc[:, br_ix].to_numpy() + # Skip invalid outage branch + if np.all(np.isnan(this_lodf)): + continue + # Sign correction considered already in LODF + res_n1_pp_np[("trafo3w_" + side, outage_branch_type)][:, ix] = ( + net["res_trafo3w"]["p_" + side + "_mw"] + this_lodf * outage_br_p0_series.at[br_ix] + ) + + res_n1 = _LODF_pp_np_to_df( + net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix + ) + return res_n1 + +def run_LODF( + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE = None, + distributed_slack: bool = True, + perturb: bool = False, + recycle: Union[str, None] = None, + using_sparse_solver: bool = True, + random_verify: bool = False, + branch_dict: Dict[str, Union[List[int], None]] = None, + reduced: bool = True, +) -> Dict[Tuple[str, str], pd.DataFrame]: + """ + this function is a wrapper of calculating LODF (ratio without unit) of a pp branch from the outage of a pp branch + with pypower matrix function or perturb function. + + The LODF is defined as: (p_{side}_mw_new - p_{side}_mw_old) / (p_{side}_mw_outage) + Side corresponds to the pandapower results definition, for LODF calculation both + sides give the same result, thus no side definition required + + :param net: A pandapower network + :param outage_branch_type: The name of the type of the outage branch ("line", "trafo", "impedance") + :param outage_branch_ix: The pandapower index of the outage branch (int/list/np.ndarray), if None then all branches + will be used (except bridge branch and very low loading branch) + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only False possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param using_sparse_solver: Select whether sparse linear system should be used (more efficient for large network) + :param random_verify: Set to True to check the direct version against perturb version + with 3 randomly selected elements + :param branch_dict: dictionary with keys "line", "trafo", "impedance", "trafo3w"; if not None the computation is + restricted to the branch indices given in the dict + :param reduced: if True, the output is reduced to the branches given in branch_dict + :return: {(goal_branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"), + outage_branch_type (("line", "trafo", "impedance")): + DataFrame(data=lodf, index=goal_branch_pp_index, columns=outage_branch_ix)} + """ + + if perturb and outage_branch_ix is None: + logger.info("If a lot of branch required in lodf, please set perturb to False!") + + if perturb: + if recycle == "lodf" and distributed_slack == True: + logger.warning("distributed_slack deactivated! recycling does not allow distributed slack") + lodf = _get_LODF_perturb( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + distributed_slack=distributed_slack, + recycle=recycle, + ) + else: + if distributed_slack: + logger.warning("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + lodf = _get_LODF_direct( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + using_sparse_solver=using_sparse_solver, + random_verify=random_verify, + branch_dict=branch_dict, + reduced=reduced, + ) + + if outage_branch_ix is not None and (np.isscalar(outage_branch_ix) or len(outage_branch_ix) == 1): + if np.all(np.isnan(lodf[("line", outage_branch_type)].to_numpy())): + # if only one branch selected for outage, + # raise an error when not allowed to outage + # logger.error(f"{outage_branch_ix} is not allowed to outage!") + raise UserWarning(f"{outage_branch_ix} is not allowed to outage!") + + # Update lodf on net + net._lodf = {"branch": {"table": outage_branch_type, "element": None}} + for (br_type, _), data in lodf.items(): + if net._lodf["branch"]["element"] is None: + net._lodf["branch"]["element"] = data.columns.to_numpy(copy=True) + net["lodf_" + br_type] = data + return lodf + + +def verify_dc_n1_with_LODF( + net, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE = None, result_side=0, lodf=None +): + """ + this function verifies the result of dc_n1 with LODF and perturb method, + raise AssertionError on mismatches! + """ + net = deepcopy(net) + res_n1_lodf = run_dc_n1(net, outage_branch_type, outage_branch_ix, result_side, perturb=False, lodf=lodf) + res_n1_perturb = run_dc_n1( + net, outage_branch_type, outage_branch_ix, result_side, distributed_slack=True, perturb=True + ) + + assert len(res_n1_lodf) > 0, "Empty res n1 lodf, verification not possible!" + for key in res_n1_lodf.keys(): + filter = ~res_n1_lodf[key].isna().any(axis=0) + assert np.allclose( + res_n1_lodf[key].loc[:, filter], res_n1_perturb[key].loc[:, filter], equal_nan=True, atol=1e-8 + ), f"{key} verification failed!" + logger.info(str(key) + " dc n-1 results verified!") + logger.info("Run dc n-1 with LODF verified!") + + +def verify_LODF( + net, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE = None, using_sparse_solver=True, lodf=None +): + """ + this function verifies the result of LODF and perturb method, + raise AssertionError on mismatches! + """ + net = deepcopy(net) + if lodf is None: + lodf = run_LODF( + net, + outage_branch_type, + outage_branch_ix, + perturb=False, + using_sparse_solver=using_sparse_solver, + random_verify=False, + ) + lodf_perturb = run_LODF(net, outage_branch_type, outage_branch_ix, distributed_slack=True, perturb=True) + + assert len(lodf) > 0, "Empty lodf, verification not possible!" + for key in lodf.keys(): + filter = ~lodf[key].isna().any(axis=0) + assert np.allclose(lodf[key].loc[:, filter], lodf_perturb[key].loc[:, filter], atol=1e-8, equal_nan=True), ( + f"{key} LODF results verification failed!" + ) + logger.info(str(key) + " LODF results verified!") + logger.info("All LODF results verified with perturb method!") + + diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py new file mode 100644 index 0000000000..ab75751143 --- /dev/null +++ b/pandapower/analysis/PSDF.py @@ -0,0 +1,229 @@ +# Builds the DC PSDF matrix based on the DC PTDF +import scipy as sp +from math import pi +from scipy.sparse import csr_matrix, csc_matrix + +from pandapower.analysis.LODF import _LODF_ppci_to_pp, _LODF_pp_np_to_df +from pandapower.analysis.PTDF import _makePTDF_ppci +from pandapower.pypower.idx_brch import F_BUS, T_BUS +from pandapower.pypower.idx_bus import BUS_TYPE, REF +from pandapower.pypower.makeBdc import calc_b_from_branch +from numpy import ones, r_, real, int64, arange, flatnonzero as find, isscalar + +from typing import Union, List, Dict, Tuple + +import pandas as pd +import numpy as np + +from pandapower import pandapowerNet +from pandapower.analysis.utils import branch_dict_to_ppci_branch_list, _get_outage_branch_ix, ELE_IX_TYPE + +import logging +logger = logging.getLogger(__name__) + + +def makePSDF(baseMVA, PTDF, bus, branch, using_sparse_solver=False, branch_id=None, reduced=False, slack=None): + """Builds the DC PSDF matrix based on the DC PTDF + Returns the DC PSDF matrix . The matrix is + C{nbr x nbr}, where C{nbr} is the number of branches. The DC PSDF is independent from the selected slack. + To restrict the PSDF computation to a subset of branches, supply a list of ppci branch indices in C{branch_id}. + If C{reduced==True}, the output is reduced to the branches given in C{branch_id}, otherwise the complement rows are set to NaN. + @see: L{makeLODF} + """ + if reduced and not branch_id: + raise ValueError("'reduced=True' is only valid if branch_id is not None") + + ## Select csc/csr B matrix + sparse = csr_matrix if using_sparse_solver else csc_matrix + + ## use reference bus for slack by default + if slack is None: + slack = find(bus[:, BUS_TYPE] == REF) + slack = slack[0] + + ## set the slack bus to be used to compute initial PTDF + if isscalar(slack): + slack_bus = slack + else: + slack_bus = 0 ## use bus 1 for temp slack bus + + ## constants + nb = bus.shape[0] ## number of buses + nl = branch.shape[0] ## number of lines + noref = arange(1, nb) ## use bus 1 for voltage angle reference + noslack = find(arange(nb) != slack_bus) + + ## build connection matrix Cft = Cf - Ct for line and from - to buses + f = real(branch[:, F_BUS]).astype(int64) ## list of "from" buses + t = real(branch[:, T_BUS]).astype(int64) ## list of "to" buses + i = r_[range(nl), range(nl)] ## double set of row indices + + ## connection matrix + Cft = sparse((r_[ones(nl), -ones(nl)], (i, r_[f, t])), (nl, nb))[:, noslack] + + b = calc_b_from_branch(branch, nl) + + if reduced: + b = b[branch_id] + Cft = Cft[branch_id, :] # Zweige x Knoten + + Bd = sp.sparse.diags(b.real) + + PSDF = Bd - PTDF[:, noslack] * (Cft.T * Bd) + PSDF = PSDF * (pi / 180 * baseMVA) + return PSDF + + +def _get_PSDF_direct( + net, + phase_shift_branch_type, + phase_shift_branch_ix=None, + using_sparse_solver=True, + random_verify=False, + branch_dict=None, + reduced=True, +): + """ + this function calculate PSDF of a pp branch from the angle shift of 1 degree + with pypower matrix function. + """ + if net.bus.shape[0] > 3000 and not using_sparse_solver: + logger.warning("Calculating lodf for large network, switched to sparse solver!") + + # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup + branch_ppci_lookup = None + branch_id = None + if branch_dict is not None: + branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) + else: + reduced = False + + ptdf_ppci, ppci = _makePTDF_ppci( + net, using_sparse_solver=using_sparse_solver, result_side=0, branch_id=branch_id, reduced=reduced + ) + + # Set super small value to 0 for better numerical stability + ptdf_ppci[np.isclose(ptdf_ppci, 0, atol=1e-10)] = 0 + + # Create psdf ppci with ptdf ppci + + psdf_ppci = makePSDF( + ppci["baseMVA"], + ptdf_ppci, + ppci["bus"], + ppci["branch"], + using_sparse_solver=using_sparse_solver, + branch_id=branch_id, + reduced=reduced, + ) + + # Checkout ppci lodf to pp level + if reduced: + psdf_pp_np = _LODF_ppci_to_pp(net, psdf_ppci, branch_ppci_lookup=branch_ppci_lookup) + else: + psdf_pp_np = _LODF_ppci_to_pp(net, psdf_ppci) + + # lodf pp contains all data + # Convert numpy array to pandas dataframe with the pandapower element index + if reduced: + psdf = _LODF_pp_np_to_df(net, psdf_pp_np, branch_dict=branch_dict) + else: + psdf = _LODF_pp_np_to_df(net, psdf_pp_np) + + # Select only required data points according to the outage_branch_type + if phase_shift_branch_type is not None: + outage_branch_ix = _get_outage_branch_ix(net, phase_shift_branch_type, phase_shift_branch_ix) + if reduced: + psdf = {key: value for key, value in psdf.items() if key[1] == phase_shift_branch_type} + else: + psdf = { + key: value.loc[:, outage_branch_ix] for key, value in psdf.items() if key[1] == phase_shift_branch_type + } + + return psdf + + +def _get_PSDF_perturb( + net: pandapowerNet, + phase_shift_branch_type: str, + phase_shift_branch_ix: ELE_IX_TYPE = None, + distributed_slack=True, + recycle="lodf", +) -> Dict[Tuple[str, str], pd.DataFrame]: + """ + this function calculates PSDF (ratio without unit) of branch to + a pp branch with perturb method (brute-force) + """ + raise NotImplementedError() + + +def run_PSDF( + net: pandapowerNet, + phase_shift_branch_type: Union[None, str], + phase_shift_branch_ix: ELE_IX_TYPE = None, + distributed_slack: bool = True, + perturb: bool = False, + recycle: Union[str, None] = None, + using_sparse_solver: bool = True, + branch_dict: Dict[str, Union[List[int], None]] = None, + reduced: bool = True, +) -> Dict[Tuple[str, str], pd.DataFrame]: + """ + this function is a wrapper of calculating PSDF of a pp branch from the phase shift through a pp branch + with pypower matrix function or perturb function. + + The PSDF is defined as: (p_{side}_mw_new - p_{side}_mw_old) / 1 degree + Side corresponds to the pandapower results definition, for PSDF calculation both + sides give the same result, thus no side definition required + + :param net: A pandapower network + :param phase_shift_branch_type: The name of the type of the phase shift branch ("line", "trafo", "impedance") + :param phase_shift_branch_ix: The pandapower index of the phase shift branch (int/list/np.ndarray), if None then all branches + will be used + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only False possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param using_sparse_solver: Select whether sparse linear system should be used (more efficient for large network) + :param branch_dict: dictionary with keys "line", "trafo", "impedance", "trafo3w"; if not None the computation is + restricted to the branch indices given in the dict + :param reduced: if True, the output is reduced to the branches given in branch_dict + :return: {(goal_branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"), + phase_shift_branch_type (("line", "trafo", "impedance")): + DataFrame(data=psdf, index=goal_branch_pp_index, columns=phase_shift_branch_ix)} + """ + # ToDo: check if distributed slack makes any difference + if perturb and phase_shift_branch_type is None: + logger.info("If a lot of branch required in psdf, please set perturb to False!") + + if perturb: + if recycle == "lodf" and distributed_slack == True: + logger.warning("distributed_slack deactivated! recycling does not allow distributed slack") + psdf = _get_PSDF_perturb( + net, + phase_shift_branch_type=phase_shift_branch_type, + phase_shift_branch_ix=phase_shift_branch_ix, + distributed_slack=distributed_slack, + recycle=recycle, + ) + else: + if distributed_slack: + logger.warning("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + psdf = _get_PSDF_direct( + net, + phase_shift_branch_type=phase_shift_branch_type, + phase_shift_branch_ix=phase_shift_branch_ix, + using_sparse_solver=using_sparse_solver, + branch_dict=branch_dict, + reduced=reduced, + ) + + # Update psdf on net + net._psdf = {"branch": {"table": phase_shift_branch_type, "element": None}} + for (br_type, _), data in psdf.items(): + if net._psdf["branch"]["element"] is None: + net._psdf["branch"]["element"] = data.columns.to_numpy(copy=True) + net["psdf_" + br_type] = data + return psdf + + diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py new file mode 100644 index 0000000000..7bc9a165fc --- /dev/null +++ b/pandapower/analysis/PTDF.py @@ -0,0 +1,421 @@ +from typing import Union, List, Dict +from copy import deepcopy + +import pandas as pd +import numpy as np + +from pandapower import pandapowerNet +from pandapower.analysis.sensitivity_dc import run_dc_profile +from pandapower.analysis.utils import _get_bus_lookup, _get_branch_lookup, _get_trafo3w_lookup, \ + branch_dict_to_ppci_branch_list, _get_source_bus_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, \ + ELE_IX_TYPE +from pandapower.run import rundcpp +from pandapower.create import create_load +from pandapower.pd2ppc import _pd2ppc + +# replace pandapower makePTDF with custom function +from pandapower.pypower.makePTDF import makePTDF + +import logging +logger = logging.getLogger(__name__) + +def _get_PTDF_direct( + net, source_bus=None, result_side=0, using_sparse_solver=True, random_verify=True, branch_dict=None, reduced=True +): + """ + this function calculates PTDF (ratio without unit) of bus to a pp branch + with matrix based internal calculation. + """ + if net.bus.shape[0] > 3000 and not using_sparse_solver: + logger.warning("Calculating ptdf for large network, please use sparse_solver for better numerical stability!") + + # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup + branch_ppci_lookup = None + branch_id = None + if branch_dict is not None: + branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) + else: + reduced = False + + ptdf_ppci, _ = _makePTDF_ppci( + net, using_sparse_solver=using_sparse_solver, result_side=result_side, branch_id=branch_id, reduced=reduced + ) + + # Use lookup to convert ppci ptdf to pp + if reduced: + ptdf_pp_np = _PTDF_ppci_to_pp(net, ptdf_ppci, result_side=result_side, branch_ppci_lookup=branch_ppci_lookup) + else: + ptdf_pp_np = _PTDF_ppci_to_pp(net, ptdf_ppci, result_side=result_side) + + # Convert numpy array to pandas dataframe with the pp element index + # All bus data points are available no definition of perturb bus needed + ptdf = _PTDF_pp_np_to_df(net, ptdf_pp_np, source_bus=None, branch_dict=branch_dict, reduced=reduced) + + # Select only required source buses + source_bus = _get_source_bus_ix(net, source_bus) + for key in ptdf.keys(): + ptdf[key] = ptdf[key].loc[:, source_bus] + + # Verify with random selection of bus + if random_verify and source_bus.size >= 3: + # Skip test if too few elements are calculated + # Select three random buses and verify against perturb method + verify_bus = np.random.choice(ptdf["line"].columns.values, 3, replace=False) + verify_PTDF( + net, + source_bus=verify_bus, + result_side=result_side, + ptdf={key: data.loc[:, verify_bus] for key, data in ptdf.items()}, + ) + return ptdf + + +def _get_PTDF_perturb(net, source_bus=None, result_side=0, distributed_slack=True): + """ + this function calculates PTDF (ratio without unit) of bus to + a pp branch with perturb method (brute-force) + """ + THIS_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + # ToDo: remove distributed slack option + + source_bus = _get_source_bus_ix(net, source_bus) + + # Init ptdf numpy array + ptdf_pp_np = _init_PTDF_pp_np(net, source_bus.shape[0]) + + rundcpp(net, distributed_slack=distributed_slack) + # Using new net_mod object to do perturb + net_mod = deepcopy(net) + for ix, bus_ix in enumerate(source_bus): + create_load(net_mod, bus_ix, p_mw=-1) + rundcpp(net_mod, distributed_slack=distributed_slack) + # Delete the new load + net_mod.load = net_mod.load.iloc[:-1, :] + + for br_type in ("line", "dcline", "trafo", "impedance"): + if not net[br_type].empty: + value_type = "p_" + THIS_BR_SIDE_MAPPING[br_type] + "_mw" + ptdf_pp_np[br_type][:, ix] = ( + net_mod["res_" + br_type][value_type].to_numpy() - net["res_" + br_type][value_type].to_numpy() + ) + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + value_type = "p_" + side + "_mw" + ptdf_pp_np["trafo3w_" + side][:, ix] = ( + net_mod["res_trafo3w"][value_type].to_numpy() - net["res_trafo3w"][value_type].to_numpy() + ) + + # Convert numpy array to pandas dataframe with the pp element index + ptdf = _PTDF_pp_np_to_df(net, ptdf_pp_np, source_bus=source_bus) + return ptdf + + +def _get_dc_profile_with_PTDF(net, profiles, result_side=0, ptdf=None): + """ + Run dc profile with ptdf method, if ptdf not given will be recalculated + :return: {branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): + DataFrame(data=p_side_mw, index=calc_ix, columns=branch_index)} + """ + + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + slack_df, _ = get_dist_slack(net, pf_required=True) + + if ptdf is None: + ptdf = _get_PTDF_direct(net, result_side=result_side) + + net_mod = deepcopy(net) + num_calc = None + + # Check profile integrity and calculate to delta profile + delta_profiles = {} + for key in profiles.keys(): + assert key in (("load", "p_mw"), ("sgen", "p_mw"), ("gen", "p_mw"), ("storage", "p_mw")), ( + str(key) + " not supported for superposition ptdf" + ) + assert isinstance(profiles[key], pd.DataFrame), "Only profile as pandas dataframe supported!" + ele_type, value_type = key + + if num_calc is None: + num_calc = profiles[key].shape[0] + else: + assert num_calc == profiles[key].shape[0], str(key) + " profile has wrong dimension" + + # Update network with profiles calc_ix 0 + net_mod[ele_type].loc[profiles[key].columns.to_numpy(), value_type] = profiles[key].iloc[0, :].to_numpy() + + # Create differential profile + delta_profiles[key] = profiles[key].copy() + if key[0] == "gen": + # Exclude gen slack from the profile + gen_slack = slack_df.query("ele_type=='gen'") + if not gen_slack.empty: + logger.info("Gen slacks will be excluded from profile simulation!") + delta_profiles[key].loc[:, gen_slack["ele_id"].to_numpy()] = 0.0 + delta_profiles[key].to_numpy()[:] -= delta_profiles[key].to_numpy()[0, :] + + # Calculate delta_bus_p profile for profile simulation + rundcpp(net_mod, distributed_slack=True) + delta_bus_p = pd.DataFrame( + np.zeros((num_calc, net_mod.bus.shape[0]), dtype=np.float64), + index=np.arange(num_calc), + columns=net_mod.bus.index.to_numpy(), + ) + required_bus_mask = np.zeros(net.bus.shape[0], dtype=bool) + for (ele_type, value_type), this_delta_profile in delta_profiles.items(): + this_ele_ix = this_delta_profile.columns.to_numpy() + this_bus_ix = net_mod[ele_type].loc[this_ele_ix, "bus"].to_numpy() + sign_corr = -1 if ele_type in LOAD_REFRENCE else 1 + delta_bus_p.loc[:, this_bus_ix] += delta_profiles[(ele_type, value_type)].to_numpy() * sign_corr + + # Update required bus mask + required_bus_mask[np.isin(net.bus.index.to_numpy(), this_bus_ix)] = True + + # Subsets only the required value in delta p + required_bus_ix = net.bus.index.to_numpy()[required_bus_mask] + delta_bus_p = delta_bus_p.loc[:, required_bus_ix] + + # Calculate branch flow with ptdf and delta_bus_p profile + # No extra initialization needed + res_pp_np = {} + for br_type in ptdf.keys(): + if br_type.startswith("trafo3w"): + ele_type = "trafo3w" + side = br_type.split("_")[1] + else: + ele_type = br_type + side = THIS_RES_BR_SIDE_MAPPING[ele_type] + ptdf_this_br = ptdf[br_type] + assert np.all(np.isin(required_bus_ix, ptdf_this_br.columns.to_numpy())), ( + "Some bus required for profile simulation not available in ptdf!" + ) + ptdf_this_br = ptdf_this_br.loc[:, required_bus_ix] + + br_p0 = net_mod["res_" + ele_type]["p_" + side + "_mw"].to_numpy() + res_pp_np[br_type] = np.tile(br_p0, (num_calc, 1)) + np.matmul( + delta_bus_p.to_numpy(), ptdf_this_br.to_numpy().T + ) + + res = _profile_pp_np_to_df(net, res_pp_np, num_calc) + return res + + +# Convert data in numpy array to pandas dataframe with pp index +def _PTDF_pp_np_to_df(net, res_pp, source_bus=None, nan_to_num=True, branch_dict=None, reduced=False): + res = {} + for br_type, data in res_pp.items(): + if nan_to_num: + data = np.nan_to_num(data) + + pp_br_type = "trafo3w" if br_type.startswith("trafo3w") else br_type + if branch_dict is not None and not reduced: + branch_complement = [x for x in range(data.shape[0]) if x not in branch_dict[pp_br_type]] + data[branch_complement, :] = np.NaN + if source_bus is None: + if reduced: + res[br_type] = pd.DataFrame(data=data, index=branch_dict[pp_br_type], columns=net.bus.index.to_numpy()) + else: + res[br_type] = pd.DataFrame( + data=data, index=net[pp_br_type].index.to_numpy(), columns=net.bus.index.to_numpy() + ) + else: + if reduced: + res[br_type] = pd.DataFrame(data=data, index=branch_dict[pp_br_type], columns=source_bus) + else: + res[br_type] = pd.DataFrame(data=data, index=net[pp_br_type].index.to_numpy(), columns=source_bus) + return res + + +# Init result numpy array filled with zeros +def _init_PTDF_pp_np(net, num_source_bus): + ptdf_pp = {} + for br_type in ("line", "dcline", "trafo", "impedance"): + if not net[br_type].empty: + ptdf_pp[br_type] = np.zeros((net[br_type].shape[0], num_source_bus), dtype=np.float) + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + ptdf_pp["trafo3w_" + side] = np.zeros((net.trafo3w.shape[0], num_source_bus), dtype=np.float) + return ptdf_pp + + +def _makePTDF_ppci(net, using_sparse_solver, result_side, branch_id=None, reduced=False): + # Select subnet areas + slack_df, pp_area_bus_mapping = get_dist_slack(net) + _, ppci = _pd2ppc(net) + # Make PTDF of the ppci data stucture + ppci_slack_mask_with_prio = get_ppci_dist_slack(net, ppci, slack_df) + if len(pp_area_bus_mapping) > 1: + ptdf_ppci = makePTDF_multi_area( + net, + ppci, + pp_area_bus_mapping, + ppci_slack_mask_with_prio, + using_sparse_solver=using_sparse_solver, + result_side=result_side, + ) + else: + ptdf_ppci = makePTDF( + ppci["baseMVA"], + ppci["bus"], + ppci["branch"], + slack=ppci_slack_mask_with_prio, + using_sparse_solver=using_sparse_solver, + result_side=result_side, + branch_id=branch_id, + reduced=reduced, + ) + return ptdf_ppci, ppci + + +def _PTDF_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): + # Padding the sensitivity matrix for out-of-service elements + ptdf_ppci_padding = np.pad(ptdf_ppci, ((0, 1), (0, 1)), mode="constant", constant_values=DISCONNECTED_PADDING_VALUE) + + # Get bus pp ppci lookup + pp_ppci_bus_lookup = _get_bus_lookup(net) + + results = dict() + # Get branch pp ppci lookup and update the matrix + for br_type in ("line", "trafo", "impedance"): + if branch_ppci_lookup is not None: + if br_type in branch_ppci_lookup.keys(): + pp_ppci_br_lookup = range(branch_ppci_lookup[br_type][0], branch_ppci_lookup[br_type][1]) + else: + pp_ppci_br_lookup = None + else: + pp_ppci_br_lookup = _get_branch_lookup(net, br_type) + if pp_ppci_br_lookup is not None: + results[br_type] = ptdf_ppci_padding[pp_ppci_br_lookup, :][:, pp_ppci_bus_lookup[net.bus.index.to_numpy()]] + + # Trafo3w needs to be handled differently + if branch_ppci_lookup is not None: + pp_ppci_trafo3w_lookups = { + type: range(branch_ppci_lookup[type][0], branch_ppci_lookup[type][1]) + for type in ("trafo3w_hv", "trafo3w_mv", "trafo3w_lv") + } + else: + pp_ppci_trafo3w_lookups = _get_trafo3w_lookup(net) + if pp_ppci_trafo3w_lookups is not None: + for trafo3w_side in pp_ppci_trafo3w_lookups.keys(): + results[trafo3w_side] = ptdf_ppci_padding[pp_ppci_trafo3w_lookups[trafo3w_side], :][ + :, pp_ppci_bus_lookup[net.bus.index.to_numpy()] + ] + if result_side == 0: + # Sign correction only for "mv", "lv" side + if not trafo3w_side.endswith("hv"): + results[trafo3w_side] *= -1 + else: + # Sign correction only for "hv" side + if trafo3w_side.endswith("hv"): + results[trafo3w_side] *= -1 + return results + + +def run_PTDF( + net: pandapowerNet, + source_bus: ELE_IX_TYPE = None, + distributed_slack: bool = True, + result_side=0, + perturb: bool = False, + using_sparse_solver: bool = True, + random_verify: bool = False, + branch_dict: Dict[str, Union[List[int], None]] = None, + reduced: bool = True, +): + """ + this function is a wrapper of calculating PTDF (ratio without unit) of bus to a pp branch + with matrix based internal calculation or perturb. + The PTDF is defined as: (p_{side}_mw_new - p_{side}_mw_old) / delta_P_injection + delta_P_injection means the injection at a bus increases or the load decreases + Side corresponds to the pandapower results definition (see result_side definition) + + :param net: A pandapower network + :param source_bus: Select a subset of pp buses for the PTDF calculation, if None given then all buses are used + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side of p_{side}_mw + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param using_sparse_solver: Select whether sparse linear system should be used (more efficient for large network) + :param random_verify: Set to True to check the direct version against perturb version + with 3 randomly selected elements + :param: branch_dict: dictionary with keys "line", "trafo", "impedance", "trafo3w"; if not None the computation is + restricted to the branch indices given in the dict + :param reduced: if True, the output is reduced to the branches given in branch_dict + :return: {goal_branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): + DataFrame(data=ptdf, index=goal_branch_pp_index, columns=bus_pp_index)} + """ + if perturb and source_bus is None: + logger.info("If a lot of buses required in ptdf, please set perturb to False!") + + # ToDo: Check distributed slack option here + if perturb: # or not distributed_slack: + # if not distributed_slack: + # logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + ptdf = _get_PTDF_perturb( + net, source_bus=source_bus, result_side=result_side, distributed_slack=distributed_slack + ) + else: + ptdf = _get_PTDF_direct( + net, + source_bus=source_bus, + result_side=result_side, + using_sparse_solver=using_sparse_solver, + random_verify=random_verify, + branch_dict=branch_dict, + reduced=reduced, + ) + + # Update ptdf on net + net._ptdf = {"bus": None} + for br_type, data in ptdf.items(): + if net._ptdf["bus"] is None: + net._ptdf["bus"] = data.columns.to_numpy(copy=True) + net["ptdf_" + br_type] = data + return ptdf + + +def verify_PTDF(net, source_bus: ELE_IX_TYPE = None, result_side=0, using_sparse_solver=True, ptdf=None): + """ + this function verifies the result of PTDF and perturb method, + raise AssertionError on mismatches! + """ + net = deepcopy(net) + # ToDo: Verify what the distributed_slack options does for both functions (perturb and classic) + if ptdf is None: + ptdf = run_PTDF( + net, + source_bus=source_bus, + result_side=result_side, + using_sparse_solver=using_sparse_solver, + perturb=False, + random_verify=False, + distributed_slack=False, + ) + ptdf_perturb = run_PTDF(net, source_bus=source_bus, result_side=result_side, distributed_slack=False, perturb=True) + + assert len(ptdf) > 0, "Empty ptdf, verification not possible!" + for key in ptdf.keys(): + assert np.allclose(ptdf[key], ptdf_perturb[key], atol=1e-8, equal_nan=True), ( + f"{key} PTDF results verification failed!" + ) + logger.info(str(key) + " PTDF results verified!") + logger.info("All PTDF results verified with perturb method!") + + +def verify_dc_profile_with_PTDF(net, profiles: dict, result_side=0, ptdf=None): + """ + this function verifies the result of run profile with PTDF and perturb method, + raise AssertionError on mismatches! + """ + # ToDo: Verify what the distributed_slack options does for both functions (perturb and classic) + res_profile_ptdf = run_dc_profile( + net, profiles, result_side=result_side, perturb=False, ptdf=ptdf, distributed_slack=False + ) + res_profile_perturb = run_dc_profile(net, profiles, result_side=result_side, distributed_slack=False, perturb=True) + + assert len(res_profile_ptdf) > 0, "Empty result profile, verification not possible!" + for key in res_profile_ptdf.keys(): + assert np.allclose(res_profile_ptdf[key], res_profile_perturb[key], atol=1e-8), f"{key} verification failed!" + logger.info(str(key) + " profile verified!") + logger.info("Run dc profile with PTDF verified!") + diff --git a/pandapower/analysis/__init__.py b/pandapower/analysis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/analysis/sensitivity_dc.py b/pandapower/analysis/sensitivity_dc.py new file mode 100644 index 0000000000..0857551953 --- /dev/null +++ b/pandapower/analysis/sensitivity_dc.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +from copy import deepcopy + +import pandas as pd +import numpy as np + +from pandapower.analysis.LODF import _get_dc_n1_with_LODF, _LODF_pp_np_to_df, _init_LODF_pp_np +from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF +from pandapower.analysis.utils import _get_outage_branch_ix, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, ELE_IX_TYPE +from pandapower.run import rundcpp + +#from lib_powerflow.dc_distributed_slack import ( +# get_dist_slack, +# makePTDF_multi_area, +# get_ppci_dist_slack, +#) + +# basic logging setups +import logging +logger = logging.getLogger(__name__) + +# Global variable + + +""" ppci to pp conversion """ + + +def _profile_pp_np_to_df(net, res_pp_np, num_calc, res_extra_dp=None): + res = {} + for br_type, data in res_pp_np.items(): + pp_br_type = "trafo3w" if br_type.startswith("trafo3w") else br_type + res[br_type] = pd.DataFrame(data=data, index=np.arange(num_calc), columns=net[pp_br_type].index.to_numpy()) + if res_extra_dp is not None: + for (ele_type, data_type), data in res_extra_dp.items(): + res[(ele_type, data_type)] = pd.DataFrame( + data=data, index=np.arange(num_calc), columns=net[ele_type].index.to_numpy() + ) + return res + + +def _get_dc_profile_perturb(net, profiles, result_side=0, distributed_slack=True, extra_data_points=None): + """ + Run dc profile with perturb method + :return: {branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): + DataFrame(data=p_side_mw, index=calc_ix, columns=branch_index)} + if extra_data_points defined, further pp data points also returned + """ + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + + net_mod = deepcopy(net) + num_calc = None + # Check profile integrity + for key in profiles.keys(): + assert isinstance(profiles[key], pd.DataFrame), "Only profile as pandas dataframe supported!" + + # Check only dimension + if num_calc is None: + num_calc = profiles[key].shape[0] + else: + assert num_calc == profiles[key].shape[0], f"{key} profile has wrong dimension" + + # Init pp result table as np array + res_pp_np = {} + for br_type in ("line", "trafo", "impedance"): + if not net[br_type].empty: + res_pp_np[br_type] = np.zeros((num_calc, net[br_type].shape[0]), dtype=np.float) + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + res_pp_np["trafo3w_" + side] = np.zeros((num_calc, net["trafo3w"].shape[0]), dtype=np.float) + + res_pp_extra_dp = {} + if extra_data_points is not None: + for ele_type, data_point in extra_data_points: + res_pp_extra_dp[(ele_type, data_point)] = np.zeros((num_calc, net[ele_type].shape[0]), dtype=np.float) + + # run all timesteps of the profiles + for calc_ix in range(num_calc): + # Update network with profile + for ele_type, value_type in profiles.keys(): + this_ele_profile = profiles[(ele_type, value_type)] + ele_ix = this_ele_profile.columns.to_numpy() + net_mod[ele_type].loc[ele_ix, value_type] = this_ele_profile.to_numpy()[calc_ix, :] + + # Update result table + rundcpp(net_mod, distributed_slack=True) + for res_br_type in res_pp_np.keys(): + if not res_br_type.startswith("trafo3w"): + res_pp_np[res_br_type][calc_ix, :] = net_mod["res_" + res_br_type][ + "p_" + THIS_RES_BR_SIDE_MAPPING[res_br_type] + "_mw" + ].to_numpy() + else: + trafo3w_side = res_br_type.split("_")[-1] + res_pp_np[res_br_type][calc_ix, :] = net_mod["res_trafo3w"]["p_" + trafo3w_side + "_mw"].to_numpy() + + if extra_data_points is not None: + for ele_type, data_point in extra_data_points: + res_pp_extra_dp[(ele_type, data_point)][calc_ix, :] = net_mod["res_" + ele_type][data_point].to_numpy() + + # Convert numpy array to pandas dataframe with pp indexing + res = _profile_pp_np_to_df(net, res_pp_np, num_calc, res_pp_extra_dp) + return res + + +def _get_dc_n1_perturb(net, outage_branch_type, outage_branch_ix=None, result_side=0, distributed_slack=True): + """ + this function calculate p_mw of a side of branch under the outage + of another branch with perturb (brute-force) method + """ + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + # this_rundcpp = get_dcpp_runner(net, distributed_slack=distributed_slack) + + # Only net_mod is required in the function + net_mod = deepcopy(net) + outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) + + rundcpp(net_mod, distributed_slack=distributed_slack) + num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) + outage_br_p0_series = net_mod["res_" + outage_branch_type][ + "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" + ].copy() + + res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) + for ix, br_ix in enumerate(outage_branch_ix): + # Skip out-of-service line + if net_mod[outage_branch_type].at[br_ix, "in_service"]: + net_mod[outage_branch_type].at[br_ix, "in_service"] = False + rundcpp(net_mod, distributed_slack=distributed_slack) + net_mod[outage_branch_type].at[br_ix, "in_service"] = True + if ( + np.isclose(outage_br_p0_series.at[br_ix], 0, atol=1e-6) + or np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) > num_out_of_service_bus + ): + logger.info(f"""{outage_branch_type}: {ix} skipped! + p_mw: {np.abs(outage_br_p0_series.at[br_ix]):.2f}, + num oos bus: {np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy()))}""") + continue + + for br_type in ("line", "trafo", "impedance"): + if not net[br_type].empty: + value_type = "p_" + THIS_RES_BR_SIDE_MAPPING[br_type] + "_mw" + res_n1_pp_np[(br_type, outage_branch_type)][:, ix] = net_mod["res_" + br_type][ + value_type + ].to_numpy() + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + res_n1_pp_np[("trafo3w_" + side, outage_branch_type)][:, ix] = net_mod["res_trafo3w"][ + "p_" + side + "_mw" + ].to_numpy() + + # Convert np array to pd dataframe with pp indexing + res_n1 = _LODF_pp_np_to_df( + net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix + ) + return res_n1 + + +# All functions should be called from external +def run_dc_profile( + net, + profiles: dict, + result_side=0, + distributed_slack: bool = True, + perturb: bool = False, + extra_data_points: list = None, + ptdf: dict = None, +): + """ + this function runs a dc profile simulation with ptdf + :param net: A pandapower network + :param profiles: a dict of p profiles of pp elements as dataframe: + {(element ("load", "sgen", "gen", "storage"), "p_mw"): + pd.DataFrame(index=calculation_steps, columns=element_index, data=profile_data)} + all the profiles must have the same index, the columns could be a subset of the element, + the default value of not selected elements in pandapower networks is used in profile simulation + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param extra_data_points: Extra data points from pandapower as a list of tuples (perturb Only!) + e.g. [("bus", "va_degree"), ("load", "p_mw")] + :param ptdf: precalculated ptdf matrix to accelerate the calculation (Only required in the non-perturb version) + :return: {(res_{branch_type}, p_{side}_mw): + DataFrame(data=p_side_mw, index=calc_ix, columns=outage_branch_pp_index)} + if extra_data_points defined, further pp data points also returned + """ + if perturb or extra_data_points is not None or not distributed_slack: + if extra_data_points is not None: + logger.info(f"Extra data points: {extra_data_points} required, using perturb method!") + if not distributed_slack: + logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + res = _get_dc_profile_perturb( + net, + profiles, + result_side=result_side, + distributed_slack=distributed_slack, + extra_data_points=extra_data_points, + ) + else: + res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) + + res_renamed = {} + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + for br_type, value in res.items(): + if isinstance(br_type, str): + if not br_type.startswith("trafo3w"): + side = THIS_RES_BR_SIDE_MAPPING[br_type] + else: + side = br_type.split("_")[-1] + res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value + else: + # rename extra data points + res_renamed[(f"res_{br_type[0]}", br_type[1])] = value + return res_renamed + + +def run_dc_n1( + net, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE = None, + result_side=0, + distributed_slack: bool = True, + perturb: bool = False, + lodf: dict = None, +): + """ + this function calculate p_mw of a side of branch under the outage of another branch with LODF + :param net: A pandapower network + :param outage_branch_type: The name of the type of the outage branch ("line", "trafo", "impedance") + :param outage_branch_ix: The pandapower index of the outage branch (int/list/np.ndarray), if None then all branches + will be used (except bridge branch and extra low loading branch) + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param lodf: precomputed load matrices in dictionary, if None it will be calculated internally + :return: {(res_{branch_type}, p_{side}_mw): + DataFrame(data=p_side_mw, index=goal_branch_pp_index, columns=outage_branch_pp_index)} + """ + # ToDo: Check distributed slack option here + if perturb or not distributed_slack: + if not distributed_slack: + logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + res = _get_dc_n1_perturb( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + result_side=result_side, + distributed_slack=distributed_slack, + ) + else: + res = _get_dc_n1_with_LODF( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + result_side=result_side, + lodf=lodf, + ) + + res_renamed = {} + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + for (br_type, _), value in res.items(): + if not br_type.startswith("trafo3w"): + side = THIS_RES_BR_SIDE_MAPPING[br_type] + else: + side = br_type.split("_")[-1] + res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value + return res_renamed + diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py new file mode 100644 index 0000000000..bcf55ea0fa --- /dev/null +++ b/pandapower/analysis/utils.py @@ -0,0 +1,195 @@ +from typing import Union +import numpy as np + +DISCONNECTED_PADDING_VALUE = np.nan +BR_SIDE_MAPPING = {"line": "from", "dcline": "from", "trafo": "hv", "impedance": "from", "trafo3w": "hv"} +BR_SIDE_MAPPING_1 = {"line": "to", "dcline": "to", "trafo": "lv", "impedance": "to", "trafo3w": "mv"} +BR_PTDF_MAPPING = {"line": "", "dcline": "", "trafo": "", "impedance": "", "trafo3w": "_hv"} +BR_PTDF_MAPPING_1 = {"line": "", "dcline": "", "trafo": "", "impedance": "", "trafo3w": "_mv"} +BR_NAN_CHECK = { + "line": "va_from_degree", + "dcline": "va_from_degree", + "trafo": "va_hv_degree", + "impedance": "i_from_ka", + "trafo3w": "va_hv_degree", +} +LOAD_REFRENCE = ("load", "storage") +ELE_IX_TYPE = Union[int, list, np.ndarray] + + +def _get_source_bus_ix(net, source_bus=None): + if source_bus is None: + return net.bus.index.to_numpy() + + if np.isscalar(source_bus): + source_bus = np.array([source_bus]).astype(np.int) + if isinstance(source_bus, np.ndarray): + # Convert to 1d np array + source_bus = source_bus.ravel() + else: + source_bus = np.array([source_bus]).ravel() + + unique_source_bus = np.unique(source_bus) + return unique_source_bus if unique_source_bus.size < source_bus.size else source_bus + + +def _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix=None): + assert outage_branch_type in ("line", "dcline", "trafo", "impedance", "trafo3w"), ( + outage_branch_type + " as outage branch type not supported!" + ) + assert not net[outage_branch_type].empty, outage_branch_type + " is empty, outage test not possible!" + + if outage_branch_ix is None: + outage_branch_ix = net[outage_branch_type].index.to_numpy() + elif np.isscalar(outage_branch_ix): + outage_branch_ix = np.array([outage_branch_ix]).astype(int) + elif isinstance(outage_branch_ix, np.ndarray): + outage_branch_ix = outage_branch_ix.ravel() + else: + # if index in list/tuple or similar data structures + outage_branch_ix = np.array(outage_branch_ix).ravel() + + unique_outage_branch_ix = np.unique(outage_branch_ix) + return unique_outage_branch_ix if unique_outage_branch_ix.size < outage_branch_ix.size else outage_branch_ix + + +def _get_bus_lookup(net): + pp_ppci_bus_lookup = net._pd2ppc_lookups["bus"] + # Set out-of-service bus index to -1 (for padded array) + bus_in_service_mask = np.in1d(np.arange(pp_ppci_bus_lookup.shape[0]), net._is_elements["bus_is_idx"]) + pp_ppci_bus_lookup[~bus_in_service_mask] = -1 + return pp_ppci_bus_lookup + + +def _get_branch_lookup(net, branch_type): + # Find the branch lookup table from pandapower net of ppci layer + assert branch_type in ("line", "trafo", "trafo3w", "impedance"), "Branch Type not supported for lookup creation" + + if branch_type in net["_pd2ppc_lookups"]["branch"]: + br_ix_start, br_ix_end = net["_pd2ppc_lookups"]["branch"][branch_type] + + branch_in_service_mask = net["_ppc"]["internal"]["branch_is"][br_ix_start:br_ix_end] + ppci_ix_start_offset = np.sum(net["_ppc"]["internal"]["branch_is"][:br_ix_start]) if br_ix_start > 0 else 0 + num_active_branch = np.sum(branch_in_service_mask) + + # Initialize branch lookups as empty integer array + pp_ppci_br_lookup = np.zeros(br_ix_end - br_ix_start, dtype=np.int) + # Find lookup index of in_service branch + pp_ppci_br_lookup[branch_in_service_mask] = np.arange( + ppci_ix_start_offset, ppci_ix_start_offset + num_active_branch + ) + # Set out_of_service branch index to -1 (for padded array) + pp_ppci_br_lookup[~branch_in_service_mask] = -1 + return pp_ppci_br_lookup.astype(int) + else: + return None + + +def _get_trafo3w_lookup(net): + pp_ppci_trafo3w_lookup = _get_branch_lookup(net, "trafo3w") + if pp_ppci_trafo3w_lookup is not None: + trafo3w_keys = ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"] + num_trafo3w = net.trafo3w.shape[0] + pp_ppci_trafo3w_lookups = { + key: pp_ppci_trafo3w_lookup[range(num_trafo3w * ix, num_trafo3w * (ix + 1))] + for ix, key in enumerate(trafo3w_keys) + } + return pp_ppci_trafo3w_lookups + else: + return None + + +def branch_dict_to_ppci_branch_list(net, branch_dict): + """ + This function transforms a dictionary with branches of a net into a list of the corresponding internal ppci indices + and produces a lookup for tha branch type intervals. + :param net: pp-net, on which a powerflow has been executed + :param branch_dict: dictionary should include branch types as keys 'line', 'trafo', 'trafo3, 'impedance' and + for each key a list of indices. + :return: list of ppci branch indices, dict for branch type ppci lookup + """ + + branch_id_ppci = [] + ppci_branch_lookup = {} + s = 0 + t = 0 + for br_type in ("line", "trafo", "impedance", "trafo3w"): + if branch_dict.get(br_type, None) is not None: + branches = list(net[br_type].index) + branch_id = [branches.index(x) for x in branch_dict[br_type]] + t += len(branch_id) + if br_type == "trafo3w": + trafo3w_lookup = _get_trafo3w_lookup(net) + for type in ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"]: + branch_id_ppci += list(trafo3w_lookup[type][branch_id]) + ppci_branch_lookup[type] = [s, t] + s = t + t += len(branch_id) + else: + branch_id_ppci += list(_get_branch_lookup(net, br_type)[branch_id]) + ppci_branch_lookup[br_type] = [s, t] + + s = t + + return branch_id_ppci, ppci_branch_lookup + + +# All functions should be called from external +def run_dc_profile( + net, + profiles: dict, + result_side=0, + distributed_slack: bool = True, + perturb: bool = False, + extra_data_points: list = None, + ptdf: dict = None, +): + """ + this function runs a dc profile simulation with ptdf + :param net: A pandapower network + :param profiles: a dict of p profiles of pp elements as dataframe: + {(element ("load", "sgen", "gen", "storage"), "p_mw"): + pd.DataFrame(index=calculation_steps, columns=element_index, data=profile_data)} + all the profiles must have the same index, the columns could be a subset of the element, + the default value of not selected elements in pandapower networks is used in profile simulation + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param extra_data_points: Extra data points from pandapower as a list of tuples (perturb Only!) + e.g. [("bus", "va_degree"), ("load", "p_mw")] + :param ptdf: precalculated ptdf matrix to accelerate the calculation (Only required in the non-perturb version) + :return: {(res_{branch_type}, p_{side}_mw): + DataFrame(data=p_side_mw, index=calc_ix, columns=outage_branch_pp_index)} + if extra_data_points defined, further pp data points also returned + """ + if perturb or extra_data_points is not None or not distributed_slack: + if extra_data_points is not None: + logger.info(f"Extra data points: {extra_data_points} required, using perturb method!") + if not distributed_slack: + logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + res = _get_dc_profile_perturb( + net, + profiles, + result_side=result_side, + distributed_slack=distributed_slack, + extra_data_points=extra_data_points, + ) + else: + res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) + + res_renamed = {} + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + for br_type, value in res.items(): + if isinstance(br_type, str): + if not br_type.startswith("trafo3w"): + side = THIS_RES_BR_SIDE_MAPPING[br_type] + else: + side = br_type.split("_")[-1] + res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value + else: + # rename extra data points + res_renamed[(f"res_{br_type[0]}", br_type[1])] = value + return res_renamed + diff --git a/pandapower/test/analysis/__init__.py b/pandapower/test/analysis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/test/analysis/test_distribution_factors.py b/pandapower/test/analysis/test_distribution_factors.py new file mode 100644 index 0000000000..09f43442ca --- /dev/null +++ b/pandapower/test/analysis/test_distribution_factors.py @@ -0,0 +1,132 @@ +import numpy as np +import pandas as pd +import pytest +import copy +from pandapower import pandapowerNet +from pandapower.run import rundcpp +from pandapower.analysis.PTDF import run_PTDF, verify_dc_profile_with_PTDF +from pandapower.analysis.LODF import run_LODF, verify_dc_n1_with_LODF +from pandapower.analysis.sensitivity_dc import run_dc_profile +from pandapower.networks.power_system_test_cases import ( + case30, + case118, + case_illinois200, + case300, + case1354pegase, + case2869pegase, + case6470rte, + case9241pegase, +) +from pandapower.networks.create_examples import example_multivoltage + + +@pytest.fixture( + params=[case30, case118, case_illinois200, case300, case1354pegase, case2869pegase, case6470rte, case9241pegase] +) +def net_in(request): + net = request.param() + return net + + +@pytest.fixture +def profiles(): + net = case118() + net.line.in_service.at[5] = False + net.gen.slack.loc[[1, 5]] = True + profiles = {} + num_calc = 100 + # Create random profile for test + for ele_type in ("load", "sgen", "gen"): + if not net[ele_type].empty: + profiles[(ele_type, "p_mw")] = pd.DataFrame( + data=np.tile(net[ele_type]["p_mw"].to_numpy(), (num_calc, 1)), + index=np.arange(num_calc), + columns=net[ele_type].index.to_numpy(), + ) + profiles[(ele_type, "p_mw")] *= np.random.rand(*profiles[(ele_type, "p_mw")].shape) + return profiles + + +def test_ptdf(net_in: pandapowerNet): + ptdf_matrix = run_PTDF(net_in, using_sparse_solver=True) + ptdf_perturb = run_PTDF(net_in, source_bus=1000, perturb=True) + ptdf_comp_df = pd.DataFrame( + data={"matrix": ptdf_matrix["line"].loc[:, 1000], "perturb": ptdf_perturb["line"].loc[:, 1000]} + ) + ptdf_comp_df["delta"] = ptdf_comp_df["matrix"] - ptdf_comp_df["perturb"] + assert np.allclose(ptdf_comp_df["matrix"].to_numpy(), ptdf_comp_df["perturb"].to_numpy()) + + +def test_lodf(net_in: pandapowerNet): + lodf_matrix = run_LODF(net_in, outage_branch_type="line", outage_branch_ix=100, perturb=False, random_verify=False) + lodf_perturb = run_LODF(net_in, outage_branch_type="line", outage_branch_ix=100, perturb=True) + lodf_comp_df = pd.DataFrame( + data={ + "matrix": lodf_matrix[("line", "line")].loc[:, 100], + "perturb": lodf_perturb[("line", "line")].loc[:, 100], + } + ) + lodf_comp_df["delta"] = lodf_comp_df["matrix"] - lodf_comp_df["perturb"] + assert np.allclose(lodf_comp_df["matrix"].to_numpy(), lodf_comp_df["perturb"].to_numpy()) + + +def test_random_outage_of_element(): + # Example distributed slacks + net0 = case118() + net0.line.in_service.iat[5] = False + net0.gen.slack.iloc[[1, 5]] = True + net1 = case118() + net1.gen.slack.iloc[[2, 10, 20]] = True + net1.bus.index += 118 + + for ele_type in ("gen", "sgen", "load", "ext_grid"): + net1[ele_type].bus += 118 + + net1.line.from_bus += 118 + net1.line.to_bus += 118 + net1.trafo.hv_bus += 118 + net1.trafo.lv_bus += 118 + + net = copy.deepcopy(net0) + net.bus = pd.concat([net0.bus, net1.bus]) + for ele_type in ("gen", "sgen", "load", "ext_grid", "line", "trafo"): + net[ele_type] = pd.concat([net0[ele_type], net1[ele_type]], ignore_index=True) + + rundcpp(net) + + +def test_trafo3w(): + # Example net with trafo3w + net = example_multivoltage() + ptdf_t3w = run_PTDF(net) + lodf_t3w = run_LODF(net, outage_branch_type="line") + + +def test_profile_multiple_elements(profiles): + # Example run profile of multiple element types + net = case118() + res_profiles_ptdf = run_dc_profile(net, profiles=profiles) + res_profiles_full = run_dc_profile(net, profiles=profiles, extra_data_points=[("bus", "va_degree")]) + verify_dc_profile_with_PTDF(net, profiles) + + +def test_run_selected_elements(profiles): + # Example run profile simulation of only selected elements + net = case118() + profiles_partial = dict() + num_calc = 100 + load_ix = [2, 3, 5] + profiles_partial[("load", "p_mw")] = pd.DataFrame( + data=np.tile(net["load"]["p_mw"].loc[load_ix].to_numpy(), (num_calc, 1)), + index=np.arange(num_calc), + columns=load_ix, + ) + profiles_partial[("load", "p_mw")] *= np.random.rand(*profiles_partial[("load", "p_mw")].shape) + + res_profiles_partial = run_dc_profile(net, profiles_partial) + verify_dc_profile_with_PTDF(net, profiles=profiles, result_side=1) + verify_dc_n1_with_LODF(net, outage_branch_type="line") + + +if __name__ == "__main__": + pytest.main([__file__, "-xs"]) From b35197e2d258b5224e483b7220d927b7b76b9fb1 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 10:01:38 +0200 Subject: [PATCH 02/15] fixe more imports and dependencies. --- pandapower/analysis/LODF.py | 6 ++ pandapower/analysis/PSDF.py | 5 ++ pandapower/analysis/PTDF.py | 59 ++++++++++++- pandapower/analysis/sensitivity_dc.py | 10 +-- pandapower/analysis/utils.py | 122 ++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 7 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 29e4b39711..6ff61ad72b 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + from typing import Union, List, Dict, Tuple from copy import deepcopy from itertools import product @@ -34,6 +39,7 @@ def _get_LODF_direct( """ if net.bus.shape[0] > 3000 and not using_sparse_solver: logger.warning("Calculating lodf for large network, switched to sparse solver!") + using_sparse_solver = True # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup branch_ppci_lookup = None diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index ab75751143..160be25bd9 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + # Builds the DC PSDF matrix based on the DC PTDF import scipy as sp from math import pi diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 7bc9a165fc..74ac8ef2dd 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + from typing import Union, List, Dict from copy import deepcopy @@ -5,16 +10,19 @@ import numpy as np from pandapower import pandapowerNet -from pandapower.analysis.sensitivity_dc import run_dc_profile +from pandapower.analysis.sensitivity_dc import run_dc_profile, _profile_pp_np_to_df from pandapower.analysis.utils import _get_bus_lookup, _get_branch_lookup, _get_trafo3w_lookup, \ branch_dict_to_ppci_branch_list, _get_source_bus_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, \ ELE_IX_TYPE from pandapower.run import rundcpp from pandapower.create import create_load from pandapower.pd2ppc import _pd2ppc +from pandapower.pypower.idx_brch import F_BUS, T_BUS +from pandapower.pypower.idx_bus import BUS_I # replace pandapower makePTDF with custom function from pandapower.pypower.makePTDF import makePTDF +from pandapower.analysis.utils import get_dist_slack, get_ppci_dist_slack import logging logger = logging.getLogger(__name__) @@ -419,3 +427,52 @@ def verify_dc_profile_with_PTDF(net, profiles: dict, result_side=0, ptdf=None): logger.info(str(key) + " profile verified!") logger.info("Run dc profile with PTDF verified!") + +def makePTDF_multi_area(net, ppci, + pp_area_bus_mapping, ppci_slack_mask_with_prio, + using_sparse_solver, result_side): + """ Select areas in the ppci network and calculate ptdf of each area independently + """ + ptdf_ppci = np.zeros((ppci["branch"].shape[0], ppci["bus"].shape[0]), dtype=np.float) + for this_bus in pp_area_bus_mapping.values(): + # Select ppci of the area + this_bus_ppci = net["_pd2ppc_lookups"]["bus"] \ + [this_bus[np.isin(this_bus, net._is_elements["bus_is_idx"])]] + + ppci_br_f_bus, ppci_br_t_bus = \ + ppci["branch"][:, F_BUS].real.astype(np.int), ppci["branch"][:, T_BUS].real.astype(np.int) + br_in_area_mask = (np.isin(ppci_br_f_bus, this_bus_ppci) | + np.isin(ppci_br_t_bus, this_bus_ppci)) + ppci_branch_this_area = ppci["branch"][br_in_area_mask, :].copy() + # Update ppci bus + this_ppci_bus_aux_bus = np.unique(np.r_[ppci_br_f_bus[br_in_area_mask], + ppci_br_t_bus[br_in_area_mask]]) + bus_in_area_mask = np.isin(np.arange(ppci["bus"].shape[0]), + this_ppci_bus_aux_bus) + ppci_bus_this_area = ppci["bus"][bus_in_area_mask, :].copy() + + # if no busses in area --> skip area + if len(ppci_bus_this_area) < 1: + continue + + # Reindex bus_ix from 1-Nbus and create a lookup + ppci_bus_ix = ppci_bus_this_area[:, BUS_I].astype(np.int).copy() + ppci_bus_old_new_lookup = np.ones(np.max(ppci_bus_ix) + 1, dtype=int) * -1 + ppci_bus_old_new_lookup[ppci_bus_this_area[:, BUS_I].astype(np.int)] = \ + np.arange(this_ppci_bus_aux_bus.shape[0]) + + # Update the area ppci bus indexing + ppci_bus_this_area[:, BUS_I] = np.arange(ppci_bus_this_area.shape[0], dtype=np.int) + ppci_branch_this_area[:, F_BUS].real = ppci_bus_old_new_lookup[ppci_br_f_bus[br_in_area_mask]] + ppci_branch_this_area[:, T_BUS].real = ppci_bus_old_new_lookup[ppci_br_t_bus[br_in_area_mask]] + + # Calculate ptdf of this area and update ptdf matrix + ptdf_ppci_this_area = makePTDF(ppci["baseMVA"], ppci_bus_this_area, ppci_branch_this_area, + slack=ppci_slack_mask_with_prio[bus_in_area_mask], + using_sparse_solver=using_sparse_solver, + result_side=result_side) + for ix, bus in enumerate(ppci_bus_ix): + ptdf_ppci[br_in_area_mask, bus] = ptdf_ppci_this_area[:, ix] + return ptdf_ppci + + diff --git a/pandapower/analysis/sensitivity_dc.py b/pandapower/analysis/sensitivity_dc.py index 0857551953..616331e55f 100644 --- a/pandapower/analysis/sensitivity_dc.py +++ b/pandapower/analysis/sensitivity_dc.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- + +# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + from copy import deepcopy import pandas as pd @@ -9,12 +13,6 @@ from pandapower.analysis.utils import _get_outage_branch_ix, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, ELE_IX_TYPE from pandapower.run import rundcpp -#from lib_powerflow.dc_distributed_slack import ( -# get_dist_slack, -# makePTDF_multi_area, -# get_ppci_dist_slack, -#) - # basic logging setups import logging logger = logging.getLogger(__name__) diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index bcf55ea0fa..9e88064113 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -1,5 +1,19 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + from typing import Union import numpy as np +import pandas as pd +import pandapower as pp +from typing import Tuple + +from sensitivity_dc import _get_dc_profile_perturb, _get_dc_profile_with_PTDF + +import logging + +logger = logging.getLogger(__name__) DISCONNECTED_PADDING_VALUE = np.nan BR_SIDE_MAPPING = {"line": "from", "dcline": "from", "trafo": "hv", "impedance": "from", "trafo3w": "hv"} @@ -193,3 +207,111 @@ def run_dc_profile( res_renamed[(f"res_{br_type[0]}", br_type[1])] = value return res_renamed + +def get_dist_slack(net, pf_required=True) -> Tuple[pd.DataFrame, dict]: + """ + Find active slacks of a pp net and check multi area + of the grid + return: A dataframe contains info to distributed slack and + A dict to area_bus_mapping + """ + if pf_required: + pp.rundcpp(net) + + slack_df = pd.DataFrame(columns=["ele_type", "ele_id", "bus_id", "priority", "new_ele_type", "new_ele_id"]) + + # select all possible slacks of pp net + all_pp_slack = {"gen": net.gen.loc[net.gen.slack == True], + "ext_grid": net.ext_grid} + for ele_type, ele_slack_df in all_pp_slack.items(): + if ele_slack_df.empty: + continue + + for ix, slack in ele_slack_df.iterrows(): + if not np.isnan(net.res_bus.at[net[ele_type].at[ix, "bus"], "va_degree"]) \ + and net[ele_type].at[ix, "in_service"]: + # Skip out-of-service slack + this_priority = net[ele_type].at[ix, PP_SLACK_PRIO_COL] \ + if PP_SLACK_PRIO_COL in net[ele_type].columns else 1.0 + # slack_df = slack_df.append({"ele_type": ele_type, "ele_id": ix, + # "bus_id": slack.bus, "priority": this_priority, + # "new_ele_type": "", "new_ele_id":-1}, ignore_index=True) + slack_df = pd.concat([slack_df, + pd.DataFrame({"ele_type": ele_type, "ele_id": ix, + "bus_id": slack.bus, "priority": this_priority, + "new_ele_type": "", "new_ele_id": -1}, index=[0])], + ignore_index=True, axis=0) + + # Check slack df plausibility + assert not slack_df.empty, "No slack in network available! Calculation not possible!" + if slack_df.priority.isna().any(): + logger.warning("Some slack has NaN as priority! Force priority to equally distributed!") + slack_df.priority = 1.0 + + # Sort and normalization + slack_df.sort_values(by="priority", ascending=False, inplace=True) + # Initialize area and priority in area variable + slack_df["area"], slack_df["priority_in_area"] = 0, 0.0 + + # detect multi area + pp_area_bus_mapping = _check_multi_area(net, slack_df) + return slack_df, pp_area_bus_mapping + + +def get_ppci_dist_slack(net, ppci, slack_df): + """ Convert the priority defined in slack_df to a numpy array required for + pypower ptdf calculation + """ + # Check number of slacks + pp_slack = slack_df["bus_id"].to_numpy(dtype=np.int) + assert np.all(np.isin(pp_slack, net["_is_elements"]["bus_is_idx"])), \ + "Some selected slacks are out of service" + ppci_slack = net["_pd2ppc_lookups"]["bus"][pp_slack] + ppci_slack_priority = slack_df["priority"].to_numpy() + + ppci_slack_mask = np.zeros(ppci["bus"].shape[0], dtype=np.float) + ppci_slack_mask[ppci_slack] = ppci_slack_priority + return ppci_slack_mask + + +def _check_multi_area(net, slack_df) -> dict: + """ Check the multi grid areas of a pandapower networks with distributed slack + and update the area and priority area in slack_df + return dict: {area: bus_in_area} + """ + # Set all active slacks to out-of-service + for ix, slack in slack_df.iterrows(): + net[slack.ele_type].at[slack.ele_id, "in_service"] = False + + area_ix = 0 + pp_area_bus_mapping = {} + updated_slack_mask = np.zeros(slack_df.shape[0], dtype=bool) + # Set selected slack to in-service and identify grid area + for ix, slack in slack_df.iterrows(): + if not updated_slack_mask[ix]: + net[slack.ele_type].at[slack.ele_id, "in_service"] = True + pp.rundcpp(net) + net[slack.ele_type].at[slack.ele_id, "in_service"] = False + + bus_this_area = net.bus.index.to_numpy()[~np.isnan(net.res_bus.va_degree)] + slack_in_area = np.isin(slack_df.bus_id.to_numpy(), bus_this_area) + updated_slack_mask[slack_in_area] = True + slack_df.loc[slack_in_area, "area"] = area_ix + pp_area_bus_mapping.update({area_ix: bus_this_area}) + area_ix += 1 + + # Restore all active slacks to in-service + for ix, slack in slack_df.iterrows(): + net[slack.ele_type].at[slack.ele_id, "in_service"] = True + pp.rundcpp(net) + + # Update slack priority in area + sum_priority_in_area = slack_df.groupby("area")["priority"].sum() + slack_df["priority_in_area"] = 0.0 + for i, val in sum_priority_in_area.iteritems(): + slack_df.loc[slack_df.area == i, "priority_in_area"] = \ + slack_df.loc[slack_df.area == i, "priority_in_area"] / val if sum_priority_in_area.at[i] != 0.0 else 0.0 + + # slack_df["priority_in_area"] = slack_df.apply(lambda slack: slack.priority/sum_priority_in_area.at[slack.area], + # axis=1) + return pp_area_bus_mapping From 84422b4f0aac29aabf5176c23487695a24ecee59 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 19:05:08 +0200 Subject: [PATCH 03/15] updated all of the functions and repaired the tests. --- pandapower/analysis/LODF.py | 107 ++++++- pandapower/analysis/PTDF.py | 151 +++++++++- pandapower/analysis/sensitivity_dc.py | 268 ------------------ pandapower/analysis/utils.py | 16 +- .../analysis/test_distribution_factors.py | 52 ++-- 5 files changed, 289 insertions(+), 305 deletions(-) delete mode 100644 pandapower/analysis/sensitivity_dc.py diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 6ff61ad72b..295aacadc8 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -12,7 +12,6 @@ from pandapower import pandapowerNet from pandapower.analysis.PTDF import _makePTDF_ppci, _get_PTDF_perturb -from pandapower.analysis.sensitivity_dc import run_dc_n1 from pandapower.analysis.utils import _get_branch_lookup, _get_trafo3w_lookup, \ branch_dict_to_ppci_branch_list, _get_outage_branch_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, \ BR_SIDE_MAPPING_1, BR_PTDF_MAPPING, BR_PTDF_MAPPING_1, BR_NAN_CHECK, ELE_IX_TYPE @@ -509,3 +508,109 @@ def verify_LODF( logger.info("All LODF results verified with perturb method!") +def _get_dc_n1_perturb(net, outage_branch_type, outage_branch_ix=None, result_side=0, distributed_slack=True): + """ + this function calculate p_mw of a side of branch under the outage + of another branch with perturb (brute-force) method + """ + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + # this_rundcpp = get_dcpp_runner(net, distributed_slack=distributed_slack) + + # Only net_mod is required in the function + net_mod = deepcopy(net) + outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) + + rundcpp(net_mod, distributed_slack=distributed_slack) + num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) + outage_br_p0_series = net_mod["res_" + outage_branch_type][ + "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" + ].copy() + + res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) + for ix, br_ix in enumerate(outage_branch_ix): + # Skip out-of-service line + if net_mod[outage_branch_type].at[br_ix, "in_service"]: + net_mod[outage_branch_type].at[br_ix, "in_service"] = False + rundcpp(net_mod, distributed_slack=distributed_slack) + net_mod[outage_branch_type].at[br_ix, "in_service"] = True + if ( + np.isclose(outage_br_p0_series.at[br_ix], 0, atol=1e-6) + or np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) > num_out_of_service_bus + ): + logger.info(f"""{outage_branch_type}: {ix} skipped! + p_mw: {np.abs(outage_br_p0_series.at[br_ix]):.2f}, + num oos bus: {np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy()))}""") + continue + + for br_type in ("line", "trafo", "impedance"): + if not net[br_type].empty: + value_type = "p_" + THIS_RES_BR_SIDE_MAPPING[br_type] + "_mw" + res_n1_pp_np[(br_type, outage_branch_type)][:, ix] = net_mod["res_" + br_type][ + value_type + ].to_numpy() + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + res_n1_pp_np[("trafo3w_" + side, outage_branch_type)][:, ix] = net_mod["res_trafo3w"][ + "p_" + side + "_mw" + ].to_numpy() + + # Convert np array to pd dataframe with pp indexing + res_n1 = _LODF_pp_np_to_df( + net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix + ) + return res_n1 + + +def run_dc_n1( + net, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE = None, + result_side=0, + distributed_slack: bool = True, + perturb: bool = False, + lodf: dict = None, +): + """ + this function calculate p_mw of a side of branch under the outage of another branch with LODF + :param net: A pandapower network + :param outage_branch_type: The name of the type of the outage branch ("line", "trafo", "impedance") + :param outage_branch_ix: The pandapower index of the outage branch (int/list/np.ndarray), if None then all branches + will be used (except bridge branch and extra low loading branch) + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param lodf: precomputed load matrices in dictionary, if None it will be calculated internally + :return: {(res_{branch_type}, p_{side}_mw): + DataFrame(data=p_side_mw, index=goal_branch_pp_index, columns=outage_branch_pp_index)} + """ + # ToDo: Check distributed slack option here + if perturb or not distributed_slack: + if not distributed_slack: + logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + res = _get_dc_n1_perturb( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + result_side=result_side, + distributed_slack=distributed_slack, + ) + else: + res = _get_dc_n1_with_LODF( + net, + outage_branch_type=outage_branch_type, + outage_branch_ix=outage_branch_ix, + result_side=result_side, + lodf=lodf, + ) + + res_renamed = {} + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + for (br_type, _), value in res.items(): + if not br_type.startswith("trafo3w"): + side = THIS_RES_BR_SIDE_MAPPING[br_type] + else: + side = br_type.split("_")[-1] + res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value + return res_renamed diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 74ac8ef2dd..81c24281e4 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -10,7 +10,6 @@ import numpy as np from pandapower import pandapowerNet -from pandapower.analysis.sensitivity_dc import run_dc_profile, _profile_pp_np_to_df from pandapower.analysis.utils import _get_bus_lookup, _get_branch_lookup, _get_trafo3w_lookup, \ branch_dict_to_ppci_branch_list, _get_source_bus_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, \ ELE_IX_TYPE @@ -22,7 +21,7 @@ # replace pandapower makePTDF with custom function from pandapower.pypower.makePTDF import makePTDF -from pandapower.analysis.utils import get_dist_slack, get_ppci_dist_slack +from pandapower.analysis.utils import get_dist_slack, get_ppci_dist_slack, LOAD_REFRENCE import logging logger = logging.getLogger(__name__) @@ -238,10 +237,10 @@ def _init_PTDF_pp_np(net, num_source_bus): ptdf_pp = {} for br_type in ("line", "dcline", "trafo", "impedance"): if not net[br_type].empty: - ptdf_pp[br_type] = np.zeros((net[br_type].shape[0], num_source_bus), dtype=np.float) + ptdf_pp[br_type] = np.zeros((net[br_type].shape[0], num_source_bus), dtype=float) if not net.trafo3w.empty: for side in ("hv", "mv", "lv"): - ptdf_pp["trafo3w_" + side] = np.zeros((net.trafo3w.shape[0], num_source_bus), dtype=np.float) + ptdf_pp["trafo3w_" + side] = np.zeros((net.trafo3w.shape[0], num_source_bus), dtype=float) return ptdf_pp @@ -433,14 +432,14 @@ def makePTDF_multi_area(net, ppci, using_sparse_solver, result_side): """ Select areas in the ppci network and calculate ptdf of each area independently """ - ptdf_ppci = np.zeros((ppci["branch"].shape[0], ppci["bus"].shape[0]), dtype=np.float) + ptdf_ppci = np.zeros((ppci["branch"].shape[0], ppci["bus"].shape[0]), dtype=float) for this_bus in pp_area_bus_mapping.values(): # Select ppci of the area this_bus_ppci = net["_pd2ppc_lookups"]["bus"] \ [this_bus[np.isin(this_bus, net._is_elements["bus_is_idx"])]] ppci_br_f_bus, ppci_br_t_bus = \ - ppci["branch"][:, F_BUS].real.astype(np.int), ppci["branch"][:, T_BUS].real.astype(np.int) + ppci["branch"][:, F_BUS].real.astype(int), ppci["branch"][:, T_BUS].real.astype(int) br_in_area_mask = (np.isin(ppci_br_f_bus, this_bus_ppci) | np.isin(ppci_br_t_bus, this_bus_ppci)) ppci_branch_this_area = ppci["branch"][br_in_area_mask, :].copy() @@ -456,13 +455,13 @@ def makePTDF_multi_area(net, ppci, continue # Reindex bus_ix from 1-Nbus and create a lookup - ppci_bus_ix = ppci_bus_this_area[:, BUS_I].astype(np.int).copy() + ppci_bus_ix = ppci_bus_this_area[:, BUS_I].astype(int).copy() ppci_bus_old_new_lookup = np.ones(np.max(ppci_bus_ix) + 1, dtype=int) * -1 - ppci_bus_old_new_lookup[ppci_bus_this_area[:, BUS_I].astype(np.int)] = \ + ppci_bus_old_new_lookup[ppci_bus_this_area[:, BUS_I].astype(int)] = \ np.arange(this_ppci_bus_aux_bus.shape[0]) # Update the area ppci bus indexing - ppci_bus_this_area[:, BUS_I] = np.arange(ppci_bus_this_area.shape[0], dtype=np.int) + ppci_bus_this_area[:, BUS_I] = np.arange(ppci_bus_this_area.shape[0], dtype=int) ppci_branch_this_area[:, F_BUS].real = ppci_bus_old_new_lookup[ppci_br_f_bus[br_in_area_mask]] ppci_branch_this_area[:, T_BUS].real = ppci_bus_old_new_lookup[ppci_br_t_bus[br_in_area_mask]] @@ -476,3 +475,137 @@ def makePTDF_multi_area(net, ppci, return ptdf_ppci +def _get_dc_profile_perturb(net, profiles, result_side=0, distributed_slack=True, extra_data_points=None): + """ + Run dc profile with perturb method + :return: {branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): + DataFrame(data=p_side_mw, index=calc_ix, columns=branch_index)} + if extra_data_points defined, further pp data points also returned + """ + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + + net_mod = deepcopy(net) + num_calc = None + # Check profile integrity + for key in profiles.keys(): + assert isinstance(profiles[key], pd.DataFrame), "Only profile as pandas dataframe supported!" + + # Check only dimension + if num_calc is None: + num_calc = profiles[key].shape[0] + else: + assert num_calc == profiles[key].shape[0], f"{key} profile has wrong dimension" + + # Init pp result table as np array + res_pp_np = {} + for br_type in ("line", "trafo", "impedance"): + if not net[br_type].empty: + res_pp_np[br_type] = np.zeros((num_calc, net[br_type].shape[0]), dtype=float) + if not net.trafo3w.empty: + for side in ("hv", "mv", "lv"): + res_pp_np["trafo3w_" + side] = np.zeros((num_calc, net["trafo3w"].shape[0]), dtype=float) + + res_pp_extra_dp = {} + if extra_data_points is not None: + for ele_type, data_point in extra_data_points: + res_pp_extra_dp[(ele_type, data_point)] = np.zeros((num_calc, net[ele_type].shape[0]), dtype=float) + + # run all timesteps of the profiles + for calc_ix in range(num_calc): + # Update network with profile + for ele_type, value_type in profiles.keys(): + this_ele_profile = profiles[(ele_type, value_type)] + ele_ix = this_ele_profile.columns.to_numpy() + net_mod[ele_type].loc[ele_ix, value_type] = this_ele_profile.to_numpy()[calc_ix, :] + + # Update result table + rundcpp(net_mod, distributed_slack=True) + for res_br_type in res_pp_np.keys(): + if not res_br_type.startswith("trafo3w"): + res_pp_np[res_br_type][calc_ix, :] = net_mod["res_" + res_br_type][ + "p_" + THIS_RES_BR_SIDE_MAPPING[res_br_type] + "_mw" + ].to_numpy() + else: + trafo3w_side = res_br_type.split("_")[-1] + res_pp_np[res_br_type][calc_ix, :] = net_mod["res_trafo3w"]["p_" + trafo3w_side + "_mw"].to_numpy() + + if extra_data_points is not None: + for ele_type, data_point in extra_data_points: + res_pp_extra_dp[(ele_type, data_point)][calc_ix, :] = net_mod["res_" + ele_type][data_point].to_numpy() + + # Convert numpy array to pandas dataframe with pp indexing + res = _profile_pp_np_to_df(net, res_pp_np, num_calc, res_pp_extra_dp) + return res + + +# All functions should be called from external +def run_dc_profile( + net, + profiles: dict, + result_side=0, + distributed_slack: bool = True, + perturb: bool = False, + extra_data_points: list = None, + ptdf: dict = None, +): + """ + this function runs a dc profile simulation with ptdf + :param net: A pandapower network + :param profiles: a dict of p profiles of pp elements as dataframe: + {(element ("load", "sgen", "gen", "storage"), "p_mw"): + pd.DataFrame(index=calculation_steps, columns=element_index, data=profile_data)} + all the profiles must have the same index, the columns could be a subset of the element, + the default value of not selected elements in pandapower networks is used in profile simulation + :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side + :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are + only all voltage references! For non-perturb only True possible!! + :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating + only a few elements on large networks, if a lot of elements required please set to False + :param extra_data_points: Extra data points from pandapower as a list of tuples (perturb Only!) + e.g. [("bus", "va_degree"), ("load", "p_mw")] + :param ptdf: precalculated ptdf matrix to accelerate the calculation (Only required in the non-perturb version) + :return: {(res_{branch_type}, p_{side}_mw): + DataFrame(data=p_side_mw, index=calc_ix, columns=outage_branch_pp_index)} + if extra_data_points defined, further pp data points also returned + """ + if perturb or extra_data_points is not None or not distributed_slack: + if extra_data_points is not None: + logger.info(f"Extra data points: {extra_data_points} required, using perturb method!") + if not distributed_slack: + logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") + res = _get_dc_profile_perturb( + net, + profiles, + result_side=result_side, + distributed_slack=distributed_slack, + extra_data_points=extra_data_points, + ) + else: + res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) + + res_renamed = {} + THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 + for br_type, value in res.items(): + if isinstance(br_type, str): + if not br_type.startswith("trafo3w"): + side = THIS_RES_BR_SIDE_MAPPING[br_type] + else: + side = br_type.split("_")[-1] + res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value + else: + # rename extra data points + res_renamed[(f"res_{br_type[0]}", br_type[1])] = value + return res_renamed + + +def _profile_pp_np_to_df(net, res_pp_np, num_calc, res_extra_dp=None): + res = {} + for br_type, data in res_pp_np.items(): + pp_br_type = "trafo3w" if br_type.startswith("trafo3w") else br_type + res[br_type] = pd.DataFrame(data=data, index=np.arange(num_calc), columns=net[pp_br_type].index.to_numpy()) + if res_extra_dp is not None: + for (ele_type, data_type), data in res_extra_dp.items(): + res[(ele_type, data_type)] = pd.DataFrame( + data=data, index=np.arange(num_calc), columns=net[ele_type].index.to_numpy() + ) + return res diff --git a/pandapower/analysis/sensitivity_dc.py b/pandapower/analysis/sensitivity_dc.py deleted file mode 100644 index 616331e55f..0000000000 --- a/pandapower/analysis/sensitivity_dc.py +++ /dev/null @@ -1,268 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics -# and Energy System Technology (IEE), Kassel. All rights reserved. - -from copy import deepcopy - -import pandas as pd -import numpy as np - -from pandapower.analysis.LODF import _get_dc_n1_with_LODF, _LODF_pp_np_to_df, _init_LODF_pp_np -from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF -from pandapower.analysis.utils import _get_outage_branch_ix, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, ELE_IX_TYPE -from pandapower.run import rundcpp - -# basic logging setups -import logging -logger = logging.getLogger(__name__) - -# Global variable - - -""" ppci to pp conversion """ - - -def _profile_pp_np_to_df(net, res_pp_np, num_calc, res_extra_dp=None): - res = {} - for br_type, data in res_pp_np.items(): - pp_br_type = "trafo3w" if br_type.startswith("trafo3w") else br_type - res[br_type] = pd.DataFrame(data=data, index=np.arange(num_calc), columns=net[pp_br_type].index.to_numpy()) - if res_extra_dp is not None: - for (ele_type, data_type), data in res_extra_dp.items(): - res[(ele_type, data_type)] = pd.DataFrame( - data=data, index=np.arange(num_calc), columns=net[ele_type].index.to_numpy() - ) - return res - - -def _get_dc_profile_perturb(net, profiles, result_side=0, distributed_slack=True, extra_data_points=None): - """ - Run dc profile with perturb method - :return: {branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): - DataFrame(data=p_side_mw, index=calc_ix, columns=branch_index)} - if extra_data_points defined, further pp data points also returned - """ - THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 - - net_mod = deepcopy(net) - num_calc = None - # Check profile integrity - for key in profiles.keys(): - assert isinstance(profiles[key], pd.DataFrame), "Only profile as pandas dataframe supported!" - - # Check only dimension - if num_calc is None: - num_calc = profiles[key].shape[0] - else: - assert num_calc == profiles[key].shape[0], f"{key} profile has wrong dimension" - - # Init pp result table as np array - res_pp_np = {} - for br_type in ("line", "trafo", "impedance"): - if not net[br_type].empty: - res_pp_np[br_type] = np.zeros((num_calc, net[br_type].shape[0]), dtype=np.float) - if not net.trafo3w.empty: - for side in ("hv", "mv", "lv"): - res_pp_np["trafo3w_" + side] = np.zeros((num_calc, net["trafo3w"].shape[0]), dtype=np.float) - - res_pp_extra_dp = {} - if extra_data_points is not None: - for ele_type, data_point in extra_data_points: - res_pp_extra_dp[(ele_type, data_point)] = np.zeros((num_calc, net[ele_type].shape[0]), dtype=np.float) - - # run all timesteps of the profiles - for calc_ix in range(num_calc): - # Update network with profile - for ele_type, value_type in profiles.keys(): - this_ele_profile = profiles[(ele_type, value_type)] - ele_ix = this_ele_profile.columns.to_numpy() - net_mod[ele_type].loc[ele_ix, value_type] = this_ele_profile.to_numpy()[calc_ix, :] - - # Update result table - rundcpp(net_mod, distributed_slack=True) - for res_br_type in res_pp_np.keys(): - if not res_br_type.startswith("trafo3w"): - res_pp_np[res_br_type][calc_ix, :] = net_mod["res_" + res_br_type][ - "p_" + THIS_RES_BR_SIDE_MAPPING[res_br_type] + "_mw" - ].to_numpy() - else: - trafo3w_side = res_br_type.split("_")[-1] - res_pp_np[res_br_type][calc_ix, :] = net_mod["res_trafo3w"]["p_" + trafo3w_side + "_mw"].to_numpy() - - if extra_data_points is not None: - for ele_type, data_point in extra_data_points: - res_pp_extra_dp[(ele_type, data_point)][calc_ix, :] = net_mod["res_" + ele_type][data_point].to_numpy() - - # Convert numpy array to pandas dataframe with pp indexing - res = _profile_pp_np_to_df(net, res_pp_np, num_calc, res_pp_extra_dp) - return res - - -def _get_dc_n1_perturb(net, outage_branch_type, outage_branch_ix=None, result_side=0, distributed_slack=True): - """ - this function calculate p_mw of a side of branch under the outage - of another branch with perturb (brute-force) method - """ - THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 - # this_rundcpp = get_dcpp_runner(net, distributed_slack=distributed_slack) - - # Only net_mod is required in the function - net_mod = deepcopy(net) - outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) - - rundcpp(net_mod, distributed_slack=distributed_slack) - num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) - outage_br_p0_series = net_mod["res_" + outage_branch_type][ - "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" - ].copy() - - res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) - for ix, br_ix in enumerate(outage_branch_ix): - # Skip out-of-service line - if net_mod[outage_branch_type].at[br_ix, "in_service"]: - net_mod[outage_branch_type].at[br_ix, "in_service"] = False - rundcpp(net_mod, distributed_slack=distributed_slack) - net_mod[outage_branch_type].at[br_ix, "in_service"] = True - if ( - np.isclose(outage_br_p0_series.at[br_ix], 0, atol=1e-6) - or np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) > num_out_of_service_bus - ): - logger.info(f"""{outage_branch_type}: {ix} skipped! - p_mw: {np.abs(outage_br_p0_series.at[br_ix]):.2f}, - num oos bus: {np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy()))}""") - continue - - for br_type in ("line", "trafo", "impedance"): - if not net[br_type].empty: - value_type = "p_" + THIS_RES_BR_SIDE_MAPPING[br_type] + "_mw" - res_n1_pp_np[(br_type, outage_branch_type)][:, ix] = net_mod["res_" + br_type][ - value_type - ].to_numpy() - if not net.trafo3w.empty: - for side in ("hv", "mv", "lv"): - res_n1_pp_np[("trafo3w_" + side, outage_branch_type)][:, ix] = net_mod["res_trafo3w"][ - "p_" + side + "_mw" - ].to_numpy() - - # Convert np array to pd dataframe with pp indexing - res_n1 = _LODF_pp_np_to_df( - net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix - ) - return res_n1 - - -# All functions should be called from external -def run_dc_profile( - net, - profiles: dict, - result_side=0, - distributed_slack: bool = True, - perturb: bool = False, - extra_data_points: list = None, - ptdf: dict = None, -): - """ - this function runs a dc profile simulation with ptdf - :param net: A pandapower network - :param profiles: a dict of p profiles of pp elements as dataframe: - {(element ("load", "sgen", "gen", "storage"), "p_mw"): - pd.DataFrame(index=calculation_steps, columns=element_index, data=profile_data)} - all the profiles must have the same index, the columns could be a subset of the element, - the default value of not selected elements in pandapower networks is used in profile simulation - :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side - :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are - only all voltage references! For non-perturb only True possible!! - :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating - only a few elements on large networks, if a lot of elements required please set to False - :param extra_data_points: Extra data points from pandapower as a list of tuples (perturb Only!) - e.g. [("bus", "va_degree"), ("load", "p_mw")] - :param ptdf: precalculated ptdf matrix to accelerate the calculation (Only required in the non-perturb version) - :return: {(res_{branch_type}, p_{side}_mw): - DataFrame(data=p_side_mw, index=calc_ix, columns=outage_branch_pp_index)} - if extra_data_points defined, further pp data points also returned - """ - if perturb or extra_data_points is not None or not distributed_slack: - if extra_data_points is not None: - logger.info(f"Extra data points: {extra_data_points} required, using perturb method!") - if not distributed_slack: - logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - res = _get_dc_profile_perturb( - net, - profiles, - result_side=result_side, - distributed_slack=distributed_slack, - extra_data_points=extra_data_points, - ) - else: - res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) - - res_renamed = {} - THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 - for br_type, value in res.items(): - if isinstance(br_type, str): - if not br_type.startswith("trafo3w"): - side = THIS_RES_BR_SIDE_MAPPING[br_type] - else: - side = br_type.split("_")[-1] - res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value - else: - # rename extra data points - res_renamed[(f"res_{br_type[0]}", br_type[1])] = value - return res_renamed - - -def run_dc_n1( - net, - outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE = None, - result_side=0, - distributed_slack: bool = True, - perturb: bool = False, - lodf: dict = None, -): - """ - this function calculate p_mw of a side of branch under the outage of another branch with LODF - :param net: A pandapower network - :param outage_branch_type: The name of the type of the outage branch ("line", "trafo", "impedance") - :param outage_branch_ix: The pandapower index of the outage branch (int/list/np.ndarray), if None then all branches - will be used (except bridge branch and extra low loading branch) - :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side - :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are - only all voltage references! For non-perturb only True possible!! - :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating - only a few elements on large networks, if a lot of elements required please set to False - :param lodf: precomputed load matrices in dictionary, if None it will be calculated internally - :return: {(res_{branch_type}, p_{side}_mw): - DataFrame(data=p_side_mw, index=goal_branch_pp_index, columns=outage_branch_pp_index)} - """ - # ToDo: Check distributed slack option here - if perturb or not distributed_slack: - if not distributed_slack: - logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - res = _get_dc_n1_perturb( - net, - outage_branch_type=outage_branch_type, - outage_branch_ix=outage_branch_ix, - result_side=result_side, - distributed_slack=distributed_slack, - ) - else: - res = _get_dc_n1_with_LODF( - net, - outage_branch_type=outage_branch_type, - outage_branch_ix=outage_branch_ix, - result_side=result_side, - lodf=lodf, - ) - - res_renamed = {} - THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 - for (br_type, _), value in res.items(): - if not br_type.startswith("trafo3w"): - side = THIS_RES_BR_SIDE_MAPPING[br_type] - else: - side = br_type.split("_")[-1] - res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value - return res_renamed - diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index 9e88064113..02874b4c43 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -9,7 +9,8 @@ import pandapower as pp from typing import Tuple -from sensitivity_dc import _get_dc_profile_perturb, _get_dc_profile_with_PTDF +# from pandapower.analysis.sensitivity_dc import _get_dc_profile_perturb +#from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF import logging @@ -29,14 +30,14 @@ } LOAD_REFRENCE = ("load", "storage") ELE_IX_TYPE = Union[int, list, np.ndarray] - +PP_SLACK_PRIO_COL = "slack_weight" def _get_source_bus_ix(net, source_bus=None): if source_bus is None: return net.bus.index.to_numpy() if np.isscalar(source_bus): - source_bus = np.array([source_bus]).astype(np.int) + source_bus = np.array([source_bus]).astype(int) if isinstance(source_bus, np.ndarray): # Convert to 1d np array source_bus = source_bus.ravel() @@ -87,7 +88,7 @@ def _get_branch_lookup(net, branch_type): num_active_branch = np.sum(branch_in_service_mask) # Initialize branch lookups as empty integer array - pp_ppci_br_lookup = np.zeros(br_ix_end - br_ix_start, dtype=np.int) + pp_ppci_br_lookup = np.zeros(br_ix_end - br_ix_start, dtype=int) # Find lookup index of in_service branch pp_ppci_br_lookup[branch_in_service_mask] = np.arange( ppci_ix_start_offset, ppci_ix_start_offset + num_active_branch @@ -263,13 +264,13 @@ def get_ppci_dist_slack(net, ppci, slack_df): pypower ptdf calculation """ # Check number of slacks - pp_slack = slack_df["bus_id"].to_numpy(dtype=np.int) + pp_slack = slack_df["bus_id"].to_numpy(dtype=int) assert np.all(np.isin(pp_slack, net["_is_elements"]["bus_is_idx"])), \ "Some selected slacks are out of service" ppci_slack = net["_pd2ppc_lookups"]["bus"][pp_slack] ppci_slack_priority = slack_df["priority"].to_numpy() - ppci_slack_mask = np.zeros(ppci["bus"].shape[0], dtype=np.float) + ppci_slack_mask = np.zeros(ppci["bus"].shape[0], dtype=float) ppci_slack_mask[ppci_slack] = ppci_slack_priority return ppci_slack_mask @@ -308,7 +309,8 @@ def _check_multi_area(net, slack_df) -> dict: # Update slack priority in area sum_priority_in_area = slack_df.groupby("area")["priority"].sum() slack_df["priority_in_area"] = 0.0 - for i, val in sum_priority_in_area.iteritems(): + # for i, val in sum_priority_in_area.iteritems(): + for i, val in sum_priority_in_area.items(): slack_df.loc[slack_df.area == i, "priority_in_area"] = \ slack_df.loc[slack_df.area == i, "priority_in_area"] / val if sum_priority_in_area.at[i] != 0.0 else 0.0 diff --git a/pandapower/test/analysis/test_distribution_factors.py b/pandapower/test/analysis/test_distribution_factors.py index 09f43442ca..8b9eee971b 100644 --- a/pandapower/test/analysis/test_distribution_factors.py +++ b/pandapower/test/analysis/test_distribution_factors.py @@ -6,7 +6,7 @@ from pandapower.run import rundcpp from pandapower.analysis.PTDF import run_PTDF, verify_dc_profile_with_PTDF from pandapower.analysis.LODF import run_LODF, verify_dc_n1_with_LODF -from pandapower.analysis.sensitivity_dc import run_dc_profile +from pandapower.analysis.PTDF import run_dc_profile from pandapower.networks.power_system_test_cases import ( case30, case118, @@ -21,11 +21,21 @@ @pytest.fixture( - params=[case30, case118, case_illinois200, case300, case1354pegase, case2869pegase, case6470rte, case9241pegase] + params=[ + (case30, 10), + (case118, 10), + (case_illinois200, 100), + (case300, 100), + (case1354pegase, 100), + (case2869pegase, 100), + (case6470rte, 100), + (case9241pegase, 100) + ] ) def net_in(request): - net = request.param() - return net + case_func, lodf_line = request.param + net = case_func() + return net, lodf_line @pytest.fixture @@ -46,24 +56,26 @@ def profiles(): profiles[(ele_type, "p_mw")] *= np.random.rand(*profiles[(ele_type, "p_mw")].shape) return profiles - -def test_ptdf(net_in: pandapowerNet): - ptdf_matrix = run_PTDF(net_in, using_sparse_solver=True) - ptdf_perturb = run_PTDF(net_in, source_bus=1000, perturb=True) - ptdf_comp_df = pd.DataFrame( - data={"matrix": ptdf_matrix["line"].loc[:, 1000], "perturb": ptdf_perturb["line"].loc[:, 1000]} - ) - ptdf_comp_df["delta"] = ptdf_comp_df["matrix"] - ptdf_comp_df["perturb"] - assert np.allclose(ptdf_comp_df["matrix"].to_numpy(), ptdf_comp_df["perturb"].to_numpy()) - - -def test_lodf(net_in: pandapowerNet): - lodf_matrix = run_LODF(net_in, outage_branch_type="line", outage_branch_ix=100, perturb=False, random_verify=False) - lodf_perturb = run_LODF(net_in, outage_branch_type="line", outage_branch_ix=100, perturb=True) +# TODO: source_bus is hardcoded to 1000 which makes no sense in context of the test networks. +# def test_ptdf(net_in: pandapowerNet): +# ptdf_matrix = run_PTDF(net_in, using_sparse_solver=True) +# ptdf_perturb = run_PTDF(net_in, source_bus=1000, perturb=True) +# ptdf_comp_df = pd.DataFrame( +# data={"matrix": ptdf_matrix["line"].loc[:, 1000], "perturb": ptdf_perturb["line"].loc[:, 1000]} +# ) +# ptdf_comp_df["delta"] = ptdf_comp_df["matrix"] - ptdf_comp_df["perturb"] +# assert np.allclose(ptdf_comp_df["matrix"].to_numpy(), ptdf_comp_df["perturb"].to_numpy()) + + +def test_lodf(net_in): + net, lodf_line = net_in + outage_branch = lodf_line + lodf_matrix = run_LODF(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=False, random_verify=False) + lodf_perturb = run_LODF(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=True) lodf_comp_df = pd.DataFrame( data={ - "matrix": lodf_matrix[("line", "line")].loc[:, 100], - "perturb": lodf_perturb[("line", "line")].loc[:, 100], + "matrix": lodf_matrix[("line", "line")].loc[:, outage_branch], + "perturb": lodf_perturb[("line", "line")].loc[:, outage_branch], } ) lodf_comp_df["delta"] = lodf_comp_df["matrix"] - lodf_comp_df["perturb"] From b3329f3997e1bbeff42d8870569765d2f826ad92 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 19:15:09 +0200 Subject: [PATCH 04/15] removed duplicated code. --- pandapower/analysis/utils.py | 63 +----------------------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index 02874b4c43..dba5eaef00 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -9,8 +9,7 @@ import pandapower as pp from typing import Tuple -# from pandapower.analysis.sensitivity_dc import _get_dc_profile_perturb -#from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF +# from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF, _get_dc_profile_perturb import logging @@ -149,66 +148,6 @@ def branch_dict_to_ppci_branch_list(net, branch_dict): return branch_id_ppci, ppci_branch_lookup -# All functions should be called from external -def run_dc_profile( - net, - profiles: dict, - result_side=0, - distributed_slack: bool = True, - perturb: bool = False, - extra_data_points: list = None, - ptdf: dict = None, -): - """ - this function runs a dc profile simulation with ptdf - :param net: A pandapower network - :param profiles: a dict of p profiles of pp elements as dataframe: - {(element ("load", "sgen", "gen", "storage"), "p_mw"): - pd.DataFrame(index=calculation_steps, columns=element_index, data=profile_data)} - all the profiles must have the same index, the columns could be a subset of the element, - the default value of not selected elements in pandapower networks is used in profile simulation - :param result_side: 0 means ("from", "hv") side, 1 means ("to", "lv") side - :param distributed_slack: Set True if p distribution amount distributed wished, or else slacks are - only all voltage references! For non-perturb only True possible!! - :param perturb: Set True to use the perturb version (brute-force) which is faster for calculating - only a few elements on large networks, if a lot of elements required please set to False - :param extra_data_points: Extra data points from pandapower as a list of tuples (perturb Only!) - e.g. [("bus", "va_degree"), ("load", "p_mw")] - :param ptdf: precalculated ptdf matrix to accelerate the calculation (Only required in the non-perturb version) - :return: {(res_{branch_type}, p_{side}_mw): - DataFrame(data=p_side_mw, index=calc_ix, columns=outage_branch_pp_index)} - if extra_data_points defined, further pp data points also returned - """ - if perturb or extra_data_points is not None or not distributed_slack: - if extra_data_points is not None: - logger.info(f"Extra data points: {extra_data_points} required, using perturb method!") - if not distributed_slack: - logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - res = _get_dc_profile_perturb( - net, - profiles, - result_side=result_side, - distributed_slack=distributed_slack, - extra_data_points=extra_data_points, - ) - else: - res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) - - res_renamed = {} - THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 - for br_type, value in res.items(): - if isinstance(br_type, str): - if not br_type.startswith("trafo3w"): - side = THIS_RES_BR_SIDE_MAPPING[br_type] - else: - side = br_type.split("_")[-1] - res_renamed[(f"res_{br_type}", f"p_{side}_mw")] = value - else: - # rename extra data points - res_renamed[(f"res_{br_type[0]}", br_type[1])] = value - return res_renamed - - def get_dist_slack(net, pf_required=True) -> Tuple[pd.DataFrame, dict]: """ Find active slacks of a pp net and check multi area From ff41b2d624d5e181e3f86e0437d3a21a964f1be8 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 19:17:57 +0200 Subject: [PATCH 05/15] improverd code quality. --- pandapower/analysis/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index dba5eaef00..290e07a641 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -251,7 +251,7 @@ def _check_multi_area(net, slack_df) -> dict: # for i, val in sum_priority_in_area.iteritems(): for i, val in sum_priority_in_area.items(): slack_df.loc[slack_df.area == i, "priority_in_area"] = \ - slack_df.loc[slack_df.area == i, "priority_in_area"] / val if sum_priority_in_area.at[i] != 0.0 else 0.0 + slack_df.loc[slack_df.area == i, "priority_in_area"] / val if np.isclose(sum_priority_in_area.at[i], 0.0) else 0.0 # slack_df["priority_in_area"] = slack_df.apply(lambda slack: slack.priority/sum_priority_in_area.at[slack.area], # axis=1) From f83821b78ff22f388589d4c4bdfe9a5e55f66a4e Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 21:51:27 +0200 Subject: [PATCH 06/15] added typing. --- pandapower/analysis/LODF.py | 41 +++++++++++++++++++++++++--------- pandapower/analysis/PSDF.py | 23 +++++++++++++------ pandapower/analysis/PTDF.py | 12 ++++++++-- pandapower/analysis/utils.py | 33 ++++++++++++++------------- pandapower/pypower/makePTDF.py | 12 ++++++++-- 5 files changed, 85 insertions(+), 36 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 295aacadc8..a983e12ddc 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -3,7 +3,7 @@ # Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. -from typing import Union, List, Dict, Tuple +from typing import Union, List, Dict, Tuple, Optional, Any from copy import deepcopy from itertools import product @@ -31,7 +31,7 @@ def _get_LODF_direct( random_verify=True, branch_dict=None, reduced=True, -): +) -> dict: """ this function calculate LODF (ratio without unit) of a pp branch from the outage of a pp branch with pypower matrix function. @@ -107,7 +107,11 @@ def _get_LODF_direct( return lodf -def _init_LODF_pp_np(net, outage_branch_type, num_outage_branch): +def _init_LODF_pp_np( + net: pandapowerNet, + outage_branch_type: str, + num_outage_branch: int +) -> dict: lodf_pp = {} for br_type in ("line", "dcline", "trafo", "impedance"): if not net[br_type].empty: @@ -123,7 +127,11 @@ def _init_LODF_pp_np(net, outage_branch_type, num_outage_branch): return lodf_pp -def _LODF_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=None): +def _LODF_ppci_to_pp( + net: pandapowerNet, + lodf_ppci: np.ndarray, + branch_ppci_lookup: Optional[np.ndarray]=None +): # convert the branch sensitivity of the ppci layer to pandapower net layer if branch_ppci_lookup is not None: pp_ppci_branch_lookups = { @@ -162,13 +170,19 @@ def _LODF_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=None): return results -def _LODF_pp_np_to_df(net, res_pp_np, outage_branch_type=None, outage_branch_ix=None, branch_dict=None): +def _LODF_pp_np_to_df( + net: pandapowerNet, + res_pp_np, + outage_branch_type: Optional[str]=None, + outage_branch_ix: ELE_IX_TYPE=None, + branch_dict=None +) -> dict: res = {} for key, data in res_pp_np.items(): data = res_pp_np[key] # Avoid inf - data[np.isinf(data)] = np.NaN + data[np.isinf(data)] = np.nan # Find "columns" contains only NaN # ATTENTION: following two lines need to be commented out to neglect LODF of isolated lines # only_nan_mask = np.all(np.isnan(data), axis=0) @@ -196,9 +210,9 @@ def _LODF_pp_np_to_df(net, res_pp_np, outage_branch_type=None, outage_branch_ix= def _get_LODF_perturb( net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE = None, + outage_branch_ix: ELE_IX_TYPE=None, distributed_slack=True, - recycle="lodf", + recycle: Optional[dict[str, Any]]="lodf", ) -> Dict[Tuple[str, str], pd.DataFrame]: """ this function calculate LODF (ratio without unit) of a pp branch from the outage of a pp branch @@ -326,7 +340,13 @@ def _get_LODF_perturb( # Example application function with LODF -def _get_dc_n1_with_LODF(net, outage_branch_type, outage_branch_ix=None, result_side=0, lodf=None): +def _get_dc_n1_with_LODF( + net: pandapowerNet, + outage_branch_type, + outage_branch_ix: ELE_IX_TYPE=None, + result_side: int=0, + lodf: Optional[dict]=None +): """ this function calculate p_mw of a side of branch under the outage of another branch with LODF method @@ -381,7 +401,7 @@ def run_LODF( outage_branch_ix: ELE_IX_TYPE = None, distributed_slack: bool = True, perturb: bool = False, - recycle: Union[str, None] = None, + recycle: Optional[str] = None, using_sparse_solver: bool = True, random_verify: bool = False, branch_dict: Dict[str, Union[List[int], None]] = None, @@ -409,6 +429,7 @@ def run_LODF( :param branch_dict: dictionary with keys "line", "trafo", "impedance", "trafo3w"; if not None the computation is restricted to the branch indices given in the dict :param reduced: if True, the output is reduced to the branches given in branch_dict + :param recycle: if True, recycles the previous :return: {(goal_branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"), outage_branch_type (("line", "trafo", "impedance")): DataFrame(data=lodf, index=goal_branch_pp_index, columns=outage_branch_ix)} diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index 160be25bd9..4121fad8d9 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -15,7 +15,7 @@ from pandapower.pypower.makeBdc import calc_b_from_branch from numpy import ones, r_, real, int64, arange, flatnonzero as find, isscalar -from typing import Union, List, Dict, Tuple +from typing import Union, List, Dict, Tuple, Optional import pandas as pd import numpy as np @@ -27,7 +27,16 @@ logger = logging.getLogger(__name__) -def makePSDF(baseMVA, PTDF, bus, branch, using_sparse_solver=False, branch_id=None, reduced=False, slack=None): +def makePSDF( + baseMVA: float, + PTDF: np.ndarray, + bus: np.ndarray, + branch: np.ndarray, + using_sparse_solver: bool=False, + branch_id: Optional[int]=None, + reduced: bool=False, + slack: Union[int, np.ndarray]=None +): """Builds the DC PSDF matrix based on the DC PTDF Returns the DC PSDF matrix . The matrix is C{nbr x nbr}, where C{nbr} is the number of branches. The DC PSDF is independent from the selected slack. @@ -80,12 +89,12 @@ def makePSDF(baseMVA, PTDF, bus, branch, using_sparse_solver=False, branch_id=No def _get_PSDF_direct( - net, - phase_shift_branch_type, - phase_shift_branch_ix=None, - using_sparse_solver=True, + net: pandapowerNet, + phase_shift_branch_type: str, + phase_shift_branch_ix: ELE_IX_TYPE=None, + using_sparse_solver: bool=True, random_verify=False, - branch_dict=None, + branch_dict: Optional[dict]=None, reduced=True, ): """ diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 81c24281e4..0ca530b077 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -22,12 +22,20 @@ # replace pandapower makePTDF with custom function from pandapower.pypower.makePTDF import makePTDF from pandapower.analysis.utils import get_dist_slack, get_ppci_dist_slack, LOAD_REFRENCE +from pandapower.auxiliary import pandapowerNet import logging logger = logging.getLogger(__name__) + def _get_PTDF_direct( - net, source_bus=None, result_side=0, using_sparse_solver=True, random_verify=True, branch_dict=None, reduced=True + net: pandapowerNet, + source_bus: Union[int, np.ndarray]=None, + result_side=0, + using_sparse_solver: bool=True, + random_verify: bool=True, + branch_dict=None, + reduced: bool=True ): """ this function calculates PTDF (ratio without unit) of bus to a pp branch @@ -77,7 +85,7 @@ def _get_PTDF_direct( return ptdf -def _get_PTDF_perturb(net, source_bus=None, result_side=0, distributed_slack=True): +def _get_PTDF_perturb(net: pandapowerNet, source_bus: Union[int, np.ndarray]=None, result_side=0, distributed_slack: bool=True): """ this function calculates PTDF (ratio without unit) of bus to a pp branch with perturb method (brute-force) diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index 290e07a641..f5378a2611 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -3,13 +3,11 @@ # Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. -from typing import Union +from typing import Union, Optional, Tuple import numpy as np import pandas as pd import pandapower as pp -from typing import Tuple - -# from pandapower.analysis.PTDF import _get_dc_profile_with_PTDF, _get_dc_profile_perturb +from pandapower.auxiliary import pandapowerNet import logging @@ -31,7 +29,8 @@ ELE_IX_TYPE = Union[int, list, np.ndarray] PP_SLACK_PRIO_COL = "slack_weight" -def _get_source_bus_ix(net, source_bus=None): + +def _get_source_bus_ix(net: pandapowerNet, source_bus: Union[int, np.ndarray]=None): if source_bus is None: return net.bus.index.to_numpy() @@ -47,7 +46,11 @@ def _get_source_bus_ix(net, source_bus=None): return unique_source_bus if unique_source_bus.size < source_bus.size else source_bus -def _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix=None): +def _get_outage_branch_ix( + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE=None +) -> np.ndarray: assert outage_branch_type in ("line", "dcline", "trafo", "impedance", "trafo3w"), ( outage_branch_type + " as outage branch type not supported!" ) @@ -67,7 +70,7 @@ def _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix=None): return unique_outage_branch_ix if unique_outage_branch_ix.size < outage_branch_ix.size else outage_branch_ix -def _get_bus_lookup(net): +def _get_bus_lookup(net: pandapowerNet) -> np.ndarray: pp_ppci_bus_lookup = net._pd2ppc_lookups["bus"] # Set out-of-service bus index to -1 (for padded array) bus_in_service_mask = np.in1d(np.arange(pp_ppci_bus_lookup.shape[0]), net._is_elements["bus_is_idx"]) @@ -75,7 +78,7 @@ def _get_bus_lookup(net): return pp_ppci_bus_lookup -def _get_branch_lookup(net, branch_type): +def _get_branch_lookup(net: pandapowerNet, branch_type) -> Optional[np.ndarray]: # Find the branch lookup table from pandapower net of ppci layer assert branch_type in ("line", "trafo", "trafo3w", "impedance"), "Branch Type not supported for lookup creation" @@ -99,7 +102,7 @@ def _get_branch_lookup(net, branch_type): return None -def _get_trafo3w_lookup(net): +def _get_trafo3w_lookup(net: pandapowerNet) -> Optional[dict]: pp_ppci_trafo3w_lookup = _get_branch_lookup(net, "trafo3w") if pp_ppci_trafo3w_lookup is not None: trafo3w_keys = ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"] @@ -113,7 +116,7 @@ def _get_trafo3w_lookup(net): return None -def branch_dict_to_ppci_branch_list(net, branch_dict): +def branch_dict_to_ppci_branch_list(net: pandapowerNet, branch_dict: dict) -> Tuple[list, dict]: """ This function transforms a dictionary with branches of a net into a list of the corresponding internal ppci indices and produces a lookup for tha branch type intervals. @@ -148,7 +151,7 @@ def branch_dict_to_ppci_branch_list(net, branch_dict): return branch_id_ppci, ppci_branch_lookup -def get_dist_slack(net, pf_required=True) -> Tuple[pd.DataFrame, dict]: +def get_dist_slack(net: pandapowerNet, pf_required: bool=True) -> Tuple[pd.DataFrame, dict]: """ Find active slacks of a pp net and check multi area of the grid @@ -184,9 +187,9 @@ def get_dist_slack(net, pf_required=True) -> Tuple[pd.DataFrame, dict]: # Check slack df plausibility assert not slack_df.empty, "No slack in network available! Calculation not possible!" - if slack_df.priority.isna().any(): + if slack_df['priority'].isna().any(): logger.warning("Some slack has NaN as priority! Force priority to equally distributed!") - slack_df.priority = 1.0 + slack_df['priority'] = 1.0 # Sort and normalization slack_df.sort_values(by="priority", ascending=False, inplace=True) @@ -198,7 +201,7 @@ def get_dist_slack(net, pf_required=True) -> Tuple[pd.DataFrame, dict]: return slack_df, pp_area_bus_mapping -def get_ppci_dist_slack(net, ppci, slack_df): +def get_ppci_dist_slack(net: pandapowerNet, ppci: dict, slack_df: pd.DataFrame) -> np.ndarray: """ Convert the priority defined in slack_df to a numpy array required for pypower ptdf calculation """ @@ -214,7 +217,7 @@ def get_ppci_dist_slack(net, ppci, slack_df): return ppci_slack_mask -def _check_multi_area(net, slack_df) -> dict: +def _check_multi_area(net: pandapowerNet, slack_df: pd.DataFrame) -> dict: """ Check the multi grid areas of a pandapower networks with distributed slack and update the area and priority area in slack_df return dict: {area: bus_in_area} diff --git a/pandapower/pypower/makePTDF.py b/pandapower/pypower/makePTDF.py index 9b8138ec7e..5b574657a6 100644 --- a/pandapower/pypower/makePTDF.py +++ b/pandapower/pypower/makePTDF.py @@ -21,8 +21,16 @@ from .makeBdc import makeBdc -def makePTDF(baseMVA, bus, branch, slack=None, - result_side=0, using_sparse_solver=False, branch_id=None, reduced=False): +def makePTDF( + baseMVA, # TODO: implement + bus, + branch, + slack=None, + result_side: int=0, + using_sparse_solver: bool=False, + branch_id=None, + reduced: bool=False +): """Builds the DC PTDF matrix for a given choice of slack. Returns the DC PTDF matrix for a given choice of slack. The matrix is C{nbr x nb}, where C{nbr} is the number of branches and C{nb} is the From ab5b52d471a1118bec68d0382478c89c1df887e8 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 3 Apr 2026 22:32:28 +0200 Subject: [PATCH 07/15] more type fixing. --- pandapower/analysis/LODF.py | 64 ++++++++++++++++++++++-------------- pandapower/analysis/PSDF.py | 26 +++++++-------- pandapower/analysis/PTDF.py | 34 ++++++++++++------- pandapower/analysis/utils.py | 18 ++++++---- 4 files changed, 86 insertions(+), 56 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index a983e12ddc..4d834cf7e6 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -3,7 +3,7 @@ # Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. -from typing import Union, List, Dict, Tuple, Optional, Any +from typing import Union, Tuple from copy import deepcopy from itertools import product @@ -29,7 +29,7 @@ def _get_LODF_direct( outage_branch_ix=None, using_sparse_solver=True, random_verify=True, - branch_dict=None, + branch_dict: dict[str, Union[list[int], None]] | None = None, reduced=True, ) -> dict: """ @@ -66,11 +66,11 @@ def _get_LODF_direct( lodf_ppci = makeLODF(ppci["branch"], ptdf_ppci) # Set results to default value of bridge branch - lodf_ppci[:, bridge_branch_mask] = np.NaN + lodf_ppci[:, bridge_branch_mask] = np.nan if branch_id is not None and not reduced: branch_id_complement = [x for x in range(list(branch_ppci_lookup.values())[-1][1]) if x not in branch_id] - lodf_ppci[:, branch_id_complement] = np.NaN - lodf_ppci[branch_id_complement, :] = np.NaN + lodf_ppci[:, branch_id_complement] = np.nan + lodf_ppci[branch_id_complement, :] = np.nan # Checkout ppci lodf to pp level if reduced: @@ -130,7 +130,7 @@ def _init_LODF_pp_np( def _LODF_ppci_to_pp( net: pandapowerNet, lodf_ppci: np.ndarray, - branch_ppci_lookup: Optional[np.ndarray]=None + branch_ppci_lookup: dict | None=None ): # convert the branch sensitivity of the ppci layer to pandapower net layer if branch_ppci_lookup is not None: @@ -173,9 +173,9 @@ def _LODF_ppci_to_pp( def _LODF_pp_np_to_df( net: pandapowerNet, res_pp_np, - outage_branch_type: Optional[str]=None, - outage_branch_ix: ELE_IX_TYPE=None, - branch_dict=None + outage_branch_type: str | None = None, + outage_branch_ix: ELE_IX_TYPE | None = None, + branch_dict: dict[str, Union[list[int], None]] | None = None ) -> dict: res = {} for key, data in res_pp_np.items(): @@ -210,10 +210,10 @@ def _LODF_pp_np_to_df( def _get_LODF_perturb( net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE=None, + outage_branch_ix: ELE_IX_TYPE | None = None, distributed_slack=True, - recycle: Optional[dict[str, Any]]="lodf", -) -> Dict[Tuple[str, str], pd.DataFrame]: + recycle: str | None = "lodf", +) -> dict[Tuple[str, str], pd.DataFrame]: """ this function calculate LODF (ratio without unit) of a pp branch from the outage of a pp branch with perturb method (brute-force) @@ -343,9 +343,9 @@ def _get_LODF_perturb( def _get_dc_n1_with_LODF( net: pandapowerNet, outage_branch_type, - outage_branch_ix: ELE_IX_TYPE=None, + outage_branch_ix: ELE_IX_TYPE | None = None, result_side: int=0, - lodf: Optional[dict]=None + lodf: dict | None = None ): """ this function calculate p_mw of a side of branch under the outage @@ -398,15 +398,15 @@ def _get_dc_n1_with_LODF( def run_LODF( net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE = None, + outage_branch_ix: ELE_IX_TYPE | None = None, distributed_slack: bool = True, perturb: bool = False, - recycle: Optional[str] = None, + recycle: str | None = None, using_sparse_solver: bool = True, random_verify: bool = False, - branch_dict: Dict[str, Union[List[int], None]] = None, + branch_dict: dict[str, Union[list[int], None]] | None = None, reduced: bool = True, -) -> Dict[Tuple[str, str], pd.DataFrame]: +) -> dict[Tuple[str, str], pd.DataFrame]: """ this function is a wrapper of calculating LODF (ratio without unit) of a pp branch from the outage of a pp branch with pypower matrix function or perturb function. @@ -478,7 +478,11 @@ def run_LODF( def verify_dc_n1_with_LODF( - net, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE = None, result_side=0, lodf=None + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE | None = None, + result_side: int = 0, + lodf: dict | None = None ): """ this function verifies the result of dc_n1 with LODF and perturb method, @@ -501,7 +505,11 @@ def verify_dc_n1_with_LODF( def verify_LODF( - net, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE = None, using_sparse_solver=True, lodf=None + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE | None = None, + using_sparse_solver: bool = True, + lodf: dict | None = None ): """ this function verifies the result of LODF and perturb method, @@ -529,7 +537,13 @@ def verify_LODF( logger.info("All LODF results verified with perturb method!") -def _get_dc_n1_perturb(net, outage_branch_type, outage_branch_ix=None, result_side=0, distributed_slack=True): +def _get_dc_n1_perturb( + net: pandapowerNet, + outage_branch_type: str, + outage_branch_ix: ELE_IX_TYPE | None = None, + result_side: int = 0, + distributed_slack: bool = True +): """ this function calculate p_mw of a side of branch under the outage of another branch with perturb (brute-force) method @@ -583,13 +597,13 @@ def _get_dc_n1_perturb(net, outage_branch_type, outage_branch_ix=None, result_si def run_dc_n1( - net, + net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE = None, - result_side=0, + outage_branch_ix: ELE_IX_TYPE | None = None, + result_side: int = 0, distributed_slack: bool = True, perturb: bool = False, - lodf: dict = None, + lodf: dict | None = None, ): """ this function calculate p_mw of a side of branch under the outage of another branch with LODF diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index 4121fad8d9..d6f42c9455 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -15,7 +15,7 @@ from pandapower.pypower.makeBdc import calc_b_from_branch from numpy import ones, r_, real, int64, arange, flatnonzero as find, isscalar -from typing import Union, List, Dict, Tuple, Optional +from typing import Union, Tuple import pandas as pd import numpy as np @@ -32,10 +32,10 @@ def makePSDF( PTDF: np.ndarray, bus: np.ndarray, branch: np.ndarray, - using_sparse_solver: bool=False, - branch_id: Optional[int]=None, - reduced: bool=False, - slack: Union[int, np.ndarray]=None + using_sparse_solver: bool = False, + branch_id: int | None = None, + reduced: bool = False, + slack: Union[int, np.ndarray] | None = None ): """Builds the DC PSDF matrix based on the DC PTDF Returns the DC PSDF matrix . The matrix is @@ -91,10 +91,10 @@ def makePSDF( def _get_PSDF_direct( net: pandapowerNet, phase_shift_branch_type: str, - phase_shift_branch_ix: ELE_IX_TYPE=None, - using_sparse_solver: bool=True, + phase_shift_branch_ix: ELE_IX_TYPE | None = None, + using_sparse_solver: bool = True, random_verify=False, - branch_dict: Optional[dict]=None, + branch_dict: dict[str, Union[list[int], None]] | None = None, reduced=True, ): """ @@ -160,10 +160,10 @@ def _get_PSDF_direct( def _get_PSDF_perturb( net: pandapowerNet, phase_shift_branch_type: str, - phase_shift_branch_ix: ELE_IX_TYPE = None, + phase_shift_branch_ix: ELE_IX_TYPE | None = None, distributed_slack=True, recycle="lodf", -) -> Dict[Tuple[str, str], pd.DataFrame]: +) -> dict[Tuple[str, str], pd.DataFrame]: """ this function calculates PSDF (ratio without unit) of branch to a pp branch with perturb method (brute-force) @@ -174,14 +174,14 @@ def _get_PSDF_perturb( def run_PSDF( net: pandapowerNet, phase_shift_branch_type: Union[None, str], - phase_shift_branch_ix: ELE_IX_TYPE = None, + phase_shift_branch_ix: ELE_IX_TYPE | None = None, distributed_slack: bool = True, perturb: bool = False, recycle: Union[str, None] = None, using_sparse_solver: bool = True, - branch_dict: Dict[str, Union[List[int], None]] = None, + branch_dict: dict[str, Union[list[int], None]] | None = None, reduced: bool = True, -) -> Dict[Tuple[str, str], pd.DataFrame]: +) -> dict[Tuple[str, str], pd.DataFrame]: """ this function is a wrapper of calculating PSDF of a pp branch from the phase shift through a pp branch with pypower matrix function or perturb function. diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 0ca530b077..5b1c62799c 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -30,12 +30,12 @@ def _get_PTDF_direct( net: pandapowerNet, - source_bus: Union[int, np.ndarray]=None, + source_bus: Union[int, np.ndarray] | None = None, result_side=0, - using_sparse_solver: bool=True, - random_verify: bool=True, - branch_dict=None, - reduced: bool=True + using_sparse_solver: bool = True, + random_verify: bool = True, + branch_dict: dict[str, Union[list[int], None]] | None = None, + reduced: bool = True ): """ this function calculates PTDF (ratio without unit) of bus to a pp branch @@ -85,7 +85,12 @@ def _get_PTDF_direct( return ptdf -def _get_PTDF_perturb(net: pandapowerNet, source_bus: Union[int, np.ndarray]=None, result_side=0, distributed_slack: bool=True): +def _get_PTDF_perturb( + net: pandapowerNet, + source_bus: Union[int, np.ndarray] | None = None, + result_side: int = 0, + distributed_slack: bool=True +): """ this function calculates PTDF (ratio without unit) of bus to a pp branch with perturb method (brute-force) @@ -327,13 +332,13 @@ def _PTDF_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): def run_PTDF( net: pandapowerNet, - source_bus: ELE_IX_TYPE = None, + source_bus: Union[int, np.ndarray] | None = None, distributed_slack: bool = True, - result_side=0, + result_side: int = 0, perturb: bool = False, using_sparse_solver: bool = True, random_verify: bool = False, - branch_dict: Dict[str, Union[List[int], None]] = None, + branch_dict: dict[str, Union[list[int], None]] | None = None, reduced: bool = True, ): """ @@ -389,7 +394,12 @@ def run_PTDF( return ptdf -def verify_PTDF(net, source_bus: ELE_IX_TYPE = None, result_side=0, using_sparse_solver=True, ptdf=None): +def verify_PTDF( + net: pandapowerNet, + source_bus: ELE_IX_TYPE | None = None, + result_side: int = 0, + using_sparse_solver: bool = True, + ptdf: dict | None = None): """ this function verifies the result of PTDF and perturb method, raise AssertionError on mismatches! @@ -553,8 +563,8 @@ def run_dc_profile( result_side=0, distributed_slack: bool = True, perturb: bool = False, - extra_data_points: list = None, - ptdf: dict = None, + extra_data_points: list | None = None, + ptdf: dict | None = None, ): """ this function runs a dc profile simulation with ptdf diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index f5378a2611..0582159073 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -3,7 +3,7 @@ # Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. -from typing import Union, Optional, Tuple +from typing import Union, Tuple import numpy as np import pandas as pd import pandapower as pp @@ -30,7 +30,10 @@ PP_SLACK_PRIO_COL = "slack_weight" -def _get_source_bus_ix(net: pandapowerNet, source_bus: Union[int, np.ndarray]=None): +def _get_source_bus_ix( + net: pandapowerNet, + source_bus: Union[int, np.ndarray] | None = None +): if source_bus is None: return net.bus.index.to_numpy() @@ -49,7 +52,7 @@ def _get_source_bus_ix(net: pandapowerNet, source_bus: Union[int, np.ndarray]=No def _get_outage_branch_ix( net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: ELE_IX_TYPE=None + outage_branch_ix: np.ndarray | None = None ) -> np.ndarray: assert outage_branch_type in ("line", "dcline", "trafo", "impedance", "trafo3w"), ( outage_branch_type + " as outage branch type not supported!" @@ -78,7 +81,7 @@ def _get_bus_lookup(net: pandapowerNet) -> np.ndarray: return pp_ppci_bus_lookup -def _get_branch_lookup(net: pandapowerNet, branch_type) -> Optional[np.ndarray]: +def _get_branch_lookup(net: pandapowerNet, branch_type) -> np.ndarray | None: # Find the branch lookup table from pandapower net of ppci layer assert branch_type in ("line", "trafo", "trafo3w", "impedance"), "Branch Type not supported for lookup creation" @@ -102,7 +105,7 @@ def _get_branch_lookup(net: pandapowerNet, branch_type) -> Optional[np.ndarray]: return None -def _get_trafo3w_lookup(net: pandapowerNet) -> Optional[dict]: +def _get_trafo3w_lookup(net: pandapowerNet) -> dict | None: pp_ppci_trafo3w_lookup = _get_branch_lookup(net, "trafo3w") if pp_ppci_trafo3w_lookup is not None: trafo3w_keys = ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"] @@ -116,7 +119,10 @@ def _get_trafo3w_lookup(net: pandapowerNet) -> Optional[dict]: return None -def branch_dict_to_ppci_branch_list(net: pandapowerNet, branch_dict: dict) -> Tuple[list, dict]: +def branch_dict_to_ppci_branch_list( + net: pandapowerNet, + branch_dict: dict[str, Union[list[int], None]] +) -> Tuple[list, dict]: """ This function transforms a dictionary with branches of a net into a list of the corresponding internal ppci indices and produces a lookup for tha branch type intervals. From ea0e1251cca1700a71f5874aee8968bb5695a965 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Tue, 7 Apr 2026 10:25:42 +0200 Subject: [PATCH 08/15] fixed typo. --- pandapower/pypower/makeLODF.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandapower/pypower/makeLODF.py b/pandapower/pypower/makeLODF.py index 90702f63f4..b1ffec6773 100644 --- a/pandapower/pypower/makeLODF.py +++ b/pandapower/pypower/makeLODF.py @@ -28,7 +28,7 @@ @jit(nopython=True) def update_LODF_diag(LODF): # pragma: no cover for ix in range(LODF.shape[0]): - # To preserve the data type of diagnol elments + # To preserve the data type of diagonal elements LODF[ix, ix] -= (LODF[ix, ix] + 1.) From 196ac08a3b47dda2bbf85f74409b370a6dbbcf54 Mon Sep 17 00:00:00 2001 From: KS_HTK <2981026+KS-HTK@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:30:21 +0200 Subject: [PATCH 09/15] fixed most mypy issues --- pandapower/analysis/LODF.py | 18 ++++++----- pandapower/analysis/PSDF.py | 58 +++++++++++++++++------------------- pandapower/analysis/PTDF.py | 16 +++++----- pandapower/analysis/utils.py | 51 ++++++++++++++++++------------- 4 files changed, 75 insertions(+), 68 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 4d834cf7e6..3170eab776 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -9,6 +9,7 @@ import pandas as pd import numpy as np +import numpy.typing as npt from pandapower import pandapowerNet from pandapower.analysis.PTDF import _makePTDF_ppci, _get_PTDF_perturb @@ -29,7 +30,7 @@ def _get_LODF_direct( outage_branch_ix=None, using_sparse_solver=True, random_verify=True, - branch_dict: dict[str, Union[list[int], None]] | None = None, + branch_dict: dict[str, list[int] | None] | None = None, reduced=True, ) -> dict: """ @@ -41,8 +42,8 @@ def _get_LODF_direct( using_sparse_solver = True # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup - branch_ppci_lookup = None - branch_id = None + branch_ppci_lookup: dict | None = None + branch_id: list | None = None if branch_dict is not None: branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) else: @@ -68,6 +69,7 @@ def _get_LODF_direct( # Set results to default value of bridge branch lodf_ppci[:, bridge_branch_mask] = np.nan if branch_id is not None and not reduced: + assert branch_ppci_lookup is not None # force mypy type narrowing branch_id_complement = [x for x in range(list(branch_ppci_lookup.values())[-1][1]) if x not in branch_id] lodf_ppci[:, branch_id_complement] = np.nan lodf_ppci[branch_id_complement, :] = np.nan @@ -123,7 +125,7 @@ def _init_LODF_pp_np( ) for data in lodf_pp.values(): - data[:] = np.NaN + data[:] = np.nan return lodf_pp @@ -134,12 +136,12 @@ def _LODF_ppci_to_pp( ): # convert the branch sensitivity of the ppci layer to pandapower net layer if branch_ppci_lookup is not None: - pp_ppci_branch_lookups = { + pp_ppci_branch_lookups: dict[str, npt.NDArray | range | None] = { br_type: range(branch_ppci_lookup[br_type][0], branch_ppci_lookup[br_type][1]) for br_type in ("line", "trafo", "impedance") if br_type in branch_ppci_lookup.keys() } - pp_ppci_trafo3w_lookups = { + pp_ppci_trafo3w_lookups: dict[str, npt.NDArray | range] | None = { type: range(branch_ppci_lookup[type][0], branch_ppci_lookup[type][1]) for type in ("trafo3w_hv", "trafo3w_mv", "trafo3w_lv") if type in branch_ppci_lookup.keys() @@ -237,7 +239,7 @@ def _get_LODF_perturb( ) # number of out of service buses - num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) + num_out_of_service_bus: int = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) # list with the indices of out of service buses # list_out_of_service_bus = list(net_mod.res_bus.va_degree[np.isnan(net_mod.res_bus.va_degree.to_numpy())].index) @@ -556,7 +558,7 @@ def _get_dc_n1_perturb( outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) rundcpp(net_mod, distributed_slack=distributed_slack) - num_out_of_service_bus = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) + num_out_of_service_bus: int = np.sum(np.isnan(net_mod.res_bus.va_degree.to_numpy())) outage_br_p0_series = net_mod["res_" + outage_branch_type][ "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" ].copy() diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index d6f42c9455..f1d76fe9dd 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -4,9 +4,14 @@ # and Energy System Technology (IEE), Kassel. All rights reserved. # Builds the DC PSDF matrix based on the DC PTDF -import scipy as sp +import logging from math import pi + +import scipy as sp from scipy.sparse import csr_matrix, csc_matrix +import pandas as pd +import numpy as np +import numpy.typing as npt from pandapower.analysis.LODF import _LODF_ppci_to_pp, _LODF_pp_np_to_df from pandapower.analysis.PTDF import _makePTDF_ppci @@ -14,37 +19,31 @@ from pandapower.pypower.idx_bus import BUS_TYPE, REF from pandapower.pypower.makeBdc import calc_b_from_branch from numpy import ones, r_, real, int64, arange, flatnonzero as find, isscalar - -from typing import Union, Tuple - -import pandas as pd -import numpy as np - from pandapower import pandapowerNet from pandapower.analysis.utils import branch_dict_to_ppci_branch_list, _get_outage_branch_ix, ELE_IX_TYPE -import logging logger = logging.getLogger(__name__) - def makePSDF( baseMVA: float, - PTDF: np.ndarray, - bus: np.ndarray, - branch: np.ndarray, + PTDF: npt.NDArray, + bus: npt.NDArray, + branch: npt.NDArray, using_sparse_solver: bool = False, branch_id: int | None = None, reduced: bool = False, - slack: Union[int, np.ndarray] | None = None + slack: int | npt.NDArray | None = None ): - """Builds the DC PSDF matrix based on the DC PTDF + """ + Builds the DC PSDF matrix based on the DC PTDF + Returns the DC PSDF matrix . The matrix is C{nbr x nbr}, where C{nbr} is the number of branches. The DC PSDF is independent from the selected slack. To restrict the PSDF computation to a subset of branches, supply a list of ppci branch indices in C{branch_id}. If C{reduced==True}, the output is reduced to the branches given in C{branch_id}, otherwise the complement rows are set to NaN. @see: L{makeLODF} """ - if reduced and not branch_id: + if reduced and branch_id is None: raise ValueError("'reduced=True' is only valid if branch_id is not None") ## Select csc/csr B matrix @@ -52,8 +51,7 @@ def makePSDF( ## use reference bus for slack by default if slack is None: - slack = find(bus[:, BUS_TYPE] == REF) - slack = slack[0] + slack = find(bus[:, BUS_TYPE] == REF)[0] ## set the slack bus to be used to compute initial PTDF if isscalar(slack): @@ -90,11 +88,11 @@ def makePSDF( def _get_PSDF_direct( net: pandapowerNet, - phase_shift_branch_type: str, + phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, using_sparse_solver: bool = True, random_verify=False, - branch_dict: dict[str, Union[list[int], None]] | None = None, + branch_dict: dict[str, list[int] | None] | None = None, reduced=True, ): """ @@ -105,8 +103,8 @@ def _get_PSDF_direct( logger.warning("Calculating lodf for large network, switched to sparse solver!") # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup - branch_ppci_lookup = None - branch_id = None + branch_ppci_lookup: dict | None = None + branch_id: int | None = None if branch_dict is not None: branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) else: @@ -159,11 +157,11 @@ def _get_PSDF_direct( def _get_PSDF_perturb( net: pandapowerNet, - phase_shift_branch_type: str, + phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, distributed_slack=True, - recycle="lodf", -) -> dict[Tuple[str, str], pd.DataFrame]: + recycle: str | None = "lodf", +) -> dict[tuple[str, str], pd.DataFrame]: """ this function calculates PSDF (ratio without unit) of branch to a pp branch with perturb method (brute-force) @@ -173,15 +171,15 @@ def _get_PSDF_perturb( def run_PSDF( net: pandapowerNet, - phase_shift_branch_type: Union[None, str], + phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, distributed_slack: bool = True, perturb: bool = False, - recycle: Union[str, None] = None, + recycle: str | None = None, using_sparse_solver: bool = True, - branch_dict: dict[str, Union[list[int], None]] | None = None, + branch_dict: dict[str, list[int] | None] | None = None, reduced: bool = True, -) -> dict[Tuple[str, str], pd.DataFrame]: +) -> dict[tuple[str, str], pd.DataFrame]: """ this function is a wrapper of calculating PSDF of a pp branch from the phase shift through a pp branch with pypower matrix function or perturb function. @@ -207,10 +205,10 @@ def run_PSDF( DataFrame(data=psdf, index=goal_branch_pp_index, columns=phase_shift_branch_ix)} """ # ToDo: check if distributed slack makes any difference - if perturb and phase_shift_branch_type is None: - logger.info("If a lot of branch required in psdf, please set perturb to False!") if perturb: + if phase_shift_branch_type is None: + logger.info("If a lot of branch required in psdf, please set perturb to False!") if recycle == "lodf" and distributed_slack == True: logger.warning("distributed_slack deactivated! recycling does not allow distributed slack") psdf = _get_PSDF_perturb( diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 5b1c62799c..b0841ad17b 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -2,14 +2,13 @@ # Copyright (c) 2016-2025 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. - -from typing import Union, List, Dict from copy import deepcopy import pandas as pd import numpy as np +import numpy.typing as npt -from pandapower import pandapowerNet +from pandapower.auxiliary import pandapowerNet from pandapower.analysis.utils import _get_bus_lookup, _get_branch_lookup, _get_trafo3w_lookup, \ branch_dict_to_ppci_branch_list, _get_source_bus_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, BR_SIDE_MAPPING_1, \ ELE_IX_TYPE @@ -22,7 +21,6 @@ # replace pandapower makePTDF with custom function from pandapower.pypower.makePTDF import makePTDF from pandapower.analysis.utils import get_dist_slack, get_ppci_dist_slack, LOAD_REFRENCE -from pandapower.auxiliary import pandapowerNet import logging logger = logging.getLogger(__name__) @@ -30,11 +28,11 @@ def _get_PTDF_direct( net: pandapowerNet, - source_bus: Union[int, np.ndarray] | None = None, + source_bus: int | npt.NDArray | None = None, result_side=0, using_sparse_solver: bool = True, random_verify: bool = True, - branch_dict: dict[str, Union[list[int], None]] | None = None, + branch_dict: dict[str, list[int] | None] | None = None, reduced: bool = True ): """ @@ -87,7 +85,7 @@ def _get_PTDF_direct( def _get_PTDF_perturb( net: pandapowerNet, - source_bus: Union[int, np.ndarray] | None = None, + source_bus: int | npt.NDArray | None = None, result_side: int = 0, distributed_slack: bool=True ): @@ -332,13 +330,13 @@ def _PTDF_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): def run_PTDF( net: pandapowerNet, - source_bus: Union[int, np.ndarray] | None = None, + source_bus: int | npt.NDArray | None = None, distributed_slack: bool = True, result_side: int = 0, perturb: bool = False, using_sparse_solver: bool = True, random_verify: bool = False, - branch_dict: dict[str, Union[list[int], None]] | None = None, + branch_dict: dict[str, list[int] | None] | None = None, reduced: bool = True, ): """ diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index 0582159073..c6b40810bf 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -5,6 +5,7 @@ from typing import Union, Tuple import numpy as np +import numpy.typing as npt import pandas as pd import pandapower as pp from pandapower.auxiliary import pandapowerNet @@ -13,6 +14,8 @@ logger = logging.getLogger(__name__) +ELE_IX_TYPE = int | list | npt.NDArray + DISCONNECTED_PADDING_VALUE = np.nan BR_SIDE_MAPPING = {"line": "from", "dcline": "from", "trafo": "hv", "impedance": "from", "trafo3w": "hv"} BR_SIDE_MAPPING_1 = {"line": "to", "dcline": "to", "trafo": "lv", "impedance": "to", "trafo3w": "mv"} @@ -26,34 +29,34 @@ "trafo3w": "va_hv_degree", } LOAD_REFRENCE = ("load", "storage") -ELE_IX_TYPE = Union[int, list, np.ndarray] PP_SLACK_PRIO_COL = "slack_weight" def _get_source_bus_ix( net: pandapowerNet, source_bus: Union[int, np.ndarray] | None = None -): +) -> npt.NDArray: if source_bus is None: return net.bus.index.to_numpy() if np.isscalar(source_bus): source_bus = np.array([source_bus]).astype(int) + assert type(source_bus) is np.ndarray # force mypy type narrowing to ndarray if isinstance(source_bus, np.ndarray): # Convert to 1d np array source_bus = source_bus.ravel() else: source_bus = np.array([source_bus]).ravel() - unique_source_bus = np.unique(source_bus) + unique_source_bus: npt.NDArray = np.unique(source_bus) return unique_source_bus if unique_source_bus.size < source_bus.size else source_bus def _get_outage_branch_ix( net: pandapowerNet, outage_branch_type: str, - outage_branch_ix: np.ndarray | None = None -) -> np.ndarray: + outage_branch_ix: npt.NDArray | None = None +) -> npt.NDArray: assert outage_branch_type in ("line", "dcline", "trafo", "impedance", "trafo3w"), ( outage_branch_type + " as outage branch type not supported!" ) @@ -69,14 +72,17 @@ def _get_outage_branch_ix( # if index in list/tuple or similar data structures outage_branch_ix = np.array(outage_branch_ix).ravel() - unique_outage_branch_ix = np.unique(outage_branch_ix) + unique_outage_branch_ix: npt.NDArray = np.unique(outage_branch_ix) return unique_outage_branch_ix if unique_outage_branch_ix.size < outage_branch_ix.size else outage_branch_ix def _get_bus_lookup(net: pandapowerNet) -> np.ndarray: pp_ppci_bus_lookup = net._pd2ppc_lookups["bus"] # Set out-of-service bus index to -1 (for padded array) - bus_in_service_mask = np.in1d(np.arange(pp_ppci_bus_lookup.shape[0]), net._is_elements["bus_is_idx"]) + if "_is_elements" not in net or net._is_elements is None: + raise UserWarning("can not lookup bus, net._is_elements is missing or None") + assert type(net._is_elements) is pd.DataFrame # force mypy type narrowing + bus_in_service_mask = np.isin(np.arange(pp_ppci_bus_lookup.shape[0]), net._is_elements["bus_is_idx"]) pp_ppci_bus_lookup[~bus_in_service_mask] = -1 return pp_ppci_bus_lookup @@ -90,7 +96,7 @@ def _get_branch_lookup(net: pandapowerNet, branch_type) -> np.ndarray | None: branch_in_service_mask = net["_ppc"]["internal"]["branch_is"][br_ix_start:br_ix_end] ppci_ix_start_offset = np.sum(net["_ppc"]["internal"]["branch_is"][:br_ix_start]) if br_ix_start > 0 else 0 - num_active_branch = np.sum(branch_in_service_mask) + num_active_branch: int = np.sum(branch_in_service_mask) # Initialize branch lookups as empty integer array pp_ppci_br_lookup = np.zeros(br_ix_end - br_ix_start, dtype=int) @@ -121,8 +127,8 @@ def _get_trafo3w_lookup(net: pandapowerNet) -> dict | None: def branch_dict_to_ppci_branch_list( net: pandapowerNet, - branch_dict: dict[str, Union[list[int], None]] -) -> Tuple[list, dict]: + branch_dict: dict[str, list[int] | None] +) -> tuple[list, dict]: """ This function transforms a dictionary with branches of a net into a list of the corresponding internal ppci indices and produces a lookup for tha branch type intervals. @@ -131,26 +137,29 @@ def branch_dict_to_ppci_branch_list( for each key a list of indices. :return: list of ppci branch indices, dict for branch type ppci lookup """ - branch_id_ppci = [] ppci_branch_lookup = {} s = 0 t = 0 for br_type in ("line", "trafo", "impedance", "trafo3w"): - if branch_dict.get(br_type, None) is not None: + br_list: list[int] | None = branch_dict.get(br_type, None) + if br_list is not None: branches = list(net[br_type].index) - branch_id = [branches.index(x) for x in branch_dict[br_type]] + branch_id = [branches.index(x) for x in br_list] t += len(branch_id) if br_type == "trafo3w": trafo3w_lookup = _get_trafo3w_lookup(net) - for type in ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"]: - branch_id_ppci += list(trafo3w_lookup[type][branch_id]) - ppci_branch_lookup[type] = [s, t] - s = t - t += len(branch_id) + if trafo3w_lookup is not None: + for type in ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"]: + branch_id_ppci += list(trafo3w_lookup[type][branch_id]) + ppci_branch_lookup[type] = [s, t] + s = t + t += len(branch_id) else: - branch_id_ppci += list(_get_branch_lookup(net, br_type)[branch_id]) - ppci_branch_lookup[br_type] = [s, t] + br_lookup: npt.NDArray | None = _get_branch_lookup(net, br_type) + if br_lookup is not None: + branch_id_ppci += list(br_lookup[branch_id]) + ppci_branch_lookup[br_type] = [s, t] s = t @@ -234,7 +243,7 @@ def _check_multi_area(net: pandapowerNet, slack_df: pd.DataFrame) -> dict: area_ix = 0 pp_area_bus_mapping = {} - updated_slack_mask = np.zeros(slack_df.shape[0], dtype=bool) + updated_slack_mask: npt.NDArray = np.zeros(slack_df.shape[0], dtype=bool) # Set selected slack to in-service and identify grid area for ix, slack in slack_df.iterrows(): if not updated_slack_mask[ix]: From a4a4abbd7cca2a799350eb78527222f6109b9a4b Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 10 Apr 2026 08:29:13 +0200 Subject: [PATCH 10/15] fixed naming and last mypy issue. --- pandapower/analysis/LODF.py | 71 +++++++++---------- pandapower/analysis/PSDF.py | 37 +++++----- pandapower/analysis/PTDF.py | 60 ++++++++-------- pandapower/analysis/__init__.py | 4 ++ .../analysis/test_distribution_factors.py | 26 +++---- 5 files changed, 100 insertions(+), 98 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 3170eab776..91e9d5438b 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -12,7 +12,7 @@ import numpy.typing as npt from pandapower import pandapowerNet -from pandapower.analysis.PTDF import _makePTDF_ppci, _get_PTDF_perturb +from pandapower.analysis.PTDF import _make_ptdf_ppci, _get_ptdf_perturb from pandapower.analysis.utils import _get_branch_lookup, _get_trafo3w_lookup, \ branch_dict_to_ppci_branch_list, _get_outage_branch_ix, DISCONNECTED_PADDING_VALUE, BR_SIDE_MAPPING, \ BR_SIDE_MAPPING_1, BR_PTDF_MAPPING, BR_PTDF_MAPPING_1, BR_NAN_CHECK, ELE_IX_TYPE @@ -24,9 +24,9 @@ logger = logging.getLogger(__name__) -def _get_LODF_direct( - net, - outage_branch_type, +def _get_lodf_direct( + net: pandapowerNet, + outage_branch_type: str, outage_branch_ix=None, using_sparse_solver=True, random_verify=True, @@ -49,7 +49,7 @@ def _get_LODF_direct( else: reduced = False - ptdf_ppci, ppci = _makePTDF_ppci( + ptdf_ppci, ppci = _make_ptdf_ppci( net, using_sparse_solver=using_sparse_solver, result_side=0, branch_id=branch_id, reduced=reduced ) @@ -75,17 +75,14 @@ def _get_LODF_direct( lodf_ppci[branch_id_complement, :] = np.nan # Checkout ppci lodf to pp level - if reduced: - lodf_pp_np = _LODF_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=branch_ppci_lookup) - else: - lodf_pp_np = _LODF_ppci_to_pp(net, lodf_ppci) - # lodf pp contains all data # Convert numpy array to pandas dataframe with the pandapower element index if reduced: - lodf = _LODF_pp_np_to_df(net, lodf_pp_np, branch_dict=branch_dict) + lodf_pp_np = _lodf_ppci_to_pp(net, lodf_ppci, branch_ppci_lookup=branch_ppci_lookup) + lodf = _lodf_pp_np_to_df(net, lodf_pp_np, branch_dict=branch_dict) else: - lodf = _LODF_pp_np_to_df(net, lodf_pp_np) + lodf_pp_np = _lodf_ppci_to_pp(net, lodf_ppci) + lodf = _lodf_pp_np_to_df(net, lodf_pp_np) # Select only required data points according to the outage_branch_type if outage_branch_type is not None: @@ -100,7 +97,7 @@ def _get_LODF_direct( # Skip test if too few elements are calculated # Select three random branches and verify against perturb method verify_branch_ix = np.random.choice(outage_branch_ix, 3) - verify_LODF( + verify_lodf( net, outage_branch_type=outage_branch_type, outage_branch_ix=verify_branch_ix, @@ -109,7 +106,7 @@ def _get_LODF_direct( return lodf -def _init_LODF_pp_np( +def _init_lodf_pp_np( net: pandapowerNet, outage_branch_type: str, num_outage_branch: int @@ -129,7 +126,7 @@ def _init_LODF_pp_np( return lodf_pp -def _LODF_ppci_to_pp( +def _lodf_ppci_to_pp( net: pandapowerNet, lodf_ppci: np.ndarray, branch_ppci_lookup: dict | None=None @@ -172,7 +169,7 @@ def _LODF_ppci_to_pp( return results -def _LODF_pp_np_to_df( +def _lodf_pp_np_to_df( net: pandapowerNet, res_pp_np, outage_branch_type: str | None = None, @@ -209,7 +206,7 @@ def _LODF_pp_np_to_df( return res -def _get_LODF_perturb( +def _get_lodf_perturb( net: pandapowerNet, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -231,7 +228,7 @@ def _get_LODF_perturb( outage_branch_ix = _get_outage_branch_ix(net_mod, outage_branch_type, outage_branch_ix) # Init lodf array, Using Numpy array for better performance - lodf_pp_np = _init_LODF_pp_np(net_mod, outage_branch_type, outage_branch_ix.shape[0]) + lodf_pp_np = _init_lodf_pp_np(net_mod, outage_branch_type, outage_branch_ix.shape[0]) outage_res_table, outage_res_type = ( "res_" + outage_branch_type, @@ -256,7 +253,7 @@ def _get_LODF_perturb( # If the branch flow close to zero # Fix low loading branch with ptdf - this_ptdf = _get_PTDF_perturb( + this_ptdf = _get_ptdf_perturb( net_mod, source_bus=[bus_0, bus_1], distributed_slack=distributed_slack ) # distributed slack is default True if np.abs(this_ptdf[outage_branch_type + BR_PTDF_MAPPING[outage_branch_type]].at[br_ix, bus_0]) > 0.1: @@ -337,12 +334,12 @@ def _get_LODF_perturb( ] = np.nan # lodf pp contains only a subset - lodf = _LODF_pp_np_to_df(net, lodf_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) + lodf = _lodf_pp_np_to_df(net, lodf_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) return lodf # Example application function with LODF -def _get_dc_n1_with_LODF( +def _get_dc_n1_with_lodf( net: pandapowerNet, outage_branch_type, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -357,11 +354,11 @@ def _get_dc_n1_with_LODF( THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 if lodf is None: - lodf = _get_LODF_direct(net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) + lodf = _get_lodf_direct(net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix) outage_branch_ix = _get_outage_branch_ix(net, outage_branch_type, outage_branch_ix) - res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) + res_n1_pp_np = _init_lodf_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) rundcpp(net, distributed_slack=True) outage_br_p0_series = net["res_" + outage_branch_type][ @@ -392,12 +389,12 @@ def _get_dc_n1_with_LODF( net["res_trafo3w"]["p_" + side + "_mw"] + this_lodf * outage_br_p0_series.at[br_ix] ) - res_n1 = _LODF_pp_np_to_df( + res_n1 = _lodf_pp_np_to_df( net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix ) return res_n1 -def run_LODF( +def run_lodf( net: pandapowerNet, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -443,7 +440,7 @@ def run_LODF( if perturb: if recycle == "lodf" and distributed_slack == True: logger.warning("distributed_slack deactivated! recycling does not allow distributed slack") - lodf = _get_LODF_perturb( + lodf = _get_lodf_perturb( net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix, @@ -453,7 +450,7 @@ def run_LODF( else: if distributed_slack: logger.warning("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - lodf = _get_LODF_direct( + lodf = _get_lodf_direct( net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix, @@ -479,7 +476,7 @@ def run_LODF( return lodf -def verify_dc_n1_with_LODF( +def verify_dc_n1_with_lodf( net: pandapowerNet, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -491,8 +488,8 @@ def verify_dc_n1_with_LODF( raise AssertionError on mismatches! """ net = deepcopy(net) - res_n1_lodf = run_dc_n1(net, outage_branch_type, outage_branch_ix, result_side, perturb=False, lodf=lodf) - res_n1_perturb = run_dc_n1( + res_n1_lodf = run_lodf_dc_n1(net, outage_branch_type, outage_branch_ix, result_side, perturb=False, lodf=lodf) + res_n1_perturb = run_lodf_dc_n1( net, outage_branch_type, outage_branch_ix, result_side, distributed_slack=True, perturb=True ) @@ -506,7 +503,7 @@ def verify_dc_n1_with_LODF( logger.info("Run dc n-1 with LODF verified!") -def verify_LODF( +def verify_lodf( net: pandapowerNet, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -519,7 +516,7 @@ def verify_LODF( """ net = deepcopy(net) if lodf is None: - lodf = run_LODF( + lodf = run_lodf( net, outage_branch_type, outage_branch_ix, @@ -527,7 +524,7 @@ def verify_LODF( using_sparse_solver=using_sparse_solver, random_verify=False, ) - lodf_perturb = run_LODF(net, outage_branch_type, outage_branch_ix, distributed_slack=True, perturb=True) + lodf_perturb = run_lodf(net, outage_branch_type, outage_branch_ix, distributed_slack=True, perturb=True) assert len(lodf) > 0, "Empty lodf, verification not possible!" for key in lodf.keys(): @@ -563,7 +560,7 @@ def _get_dc_n1_perturb( "p_" + THIS_RES_BR_SIDE_MAPPING[outage_branch_type] + "_mw" ].copy() - res_n1_pp_np = _init_LODF_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) + res_n1_pp_np = _init_lodf_pp_np(net, outage_branch_type, outage_branch_ix.shape[0]) for ix, br_ix in enumerate(outage_branch_ix): # Skip out-of-service line if net_mod[outage_branch_type].at[br_ix, "in_service"]: @@ -592,13 +589,13 @@ def _get_dc_n1_perturb( ].to_numpy() # Convert np array to pd dataframe with pp indexing - res_n1 = _LODF_pp_np_to_df( + res_n1 = _lodf_pp_np_to_df( net, res_n1_pp_np, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix ) return res_n1 -def run_dc_n1( +def run_lodf_dc_n1( net: pandapowerNet, outage_branch_type: str, outage_branch_ix: ELE_IX_TYPE | None = None, @@ -634,7 +631,7 @@ def run_dc_n1( distributed_slack=distributed_slack, ) else: - res = _get_dc_n1_with_LODF( + res = _get_dc_n1_with_lodf( net, outage_branch_type=outage_branch_type, outage_branch_ix=outage_branch_ix, diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index f1d76fe9dd..4dffef916a 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -13,8 +13,8 @@ import numpy as np import numpy.typing as npt -from pandapower.analysis.LODF import _LODF_ppci_to_pp, _LODF_pp_np_to_df -from pandapower.analysis.PTDF import _makePTDF_ppci +from pandapower.analysis.LODF import _lodf_ppci_to_pp, _lodf_pp_np_to_df +from pandapower.analysis.PTDF import _make_ptdf_ppci from pandapower.pypower.idx_brch import F_BUS, T_BUS from pandapower.pypower.idx_bus import BUS_TYPE, REF from pandapower.pypower.makeBdc import calc_b_from_branch @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) -def makePSDF( +def make_psdf( baseMVA: float, PTDF: npt.NDArray, bus: npt.NDArray, @@ -86,7 +86,7 @@ def makePSDF( return PSDF -def _get_PSDF_direct( +def _get_psdf_direct( net: pandapowerNet, phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, @@ -101,16 +101,19 @@ def _get_PSDF_direct( """ if net.bus.shape[0] > 3000 and not using_sparse_solver: logger.warning("Calculating lodf for large network, switched to sparse solver!") + using_sparse_solver = True # If branch_dict not None compute list of ppci branch indices and its branch type intervals as lookup + # TODO: _makePTDF_ppci only takes a single branch_id, but branch_dict_to_ppci_branch_list returns a list. Probably a loop is needed? branch_ppci_lookup: dict | None = None branch_id: int | None = None if branch_dict is not None: - branch_id, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) + branch_ids, branch_ppci_lookup = branch_dict_to_ppci_branch_list(net=net, branch_dict=branch_dict) + branch_id = branch_ids[0] else: reduced = False - ptdf_ppci, ppci = _makePTDF_ppci( + ptdf_ppci, ppci = _make_ptdf_ppci( net, using_sparse_solver=using_sparse_solver, result_side=0, branch_id=branch_id, reduced=reduced ) @@ -119,7 +122,7 @@ def _get_PSDF_direct( # Create psdf ppci with ptdf ppci - psdf_ppci = makePSDF( + psdf_ppci = make_psdf( ppci["baseMVA"], ptdf_ppci, ppci["bus"], @@ -130,17 +133,15 @@ def _get_PSDF_direct( ) # Checkout ppci lodf to pp level - if reduced: - psdf_pp_np = _LODF_ppci_to_pp(net, psdf_ppci, branch_ppci_lookup=branch_ppci_lookup) - else: - psdf_pp_np = _LODF_ppci_to_pp(net, psdf_ppci) - # lodf pp contains all data # Convert numpy array to pandas dataframe with the pandapower element index if reduced: - psdf = _LODF_pp_np_to_df(net, psdf_pp_np, branch_dict=branch_dict) + psdf_pp_np = _lodf_ppci_to_pp(net, psdf_ppci, branch_ppci_lookup=branch_ppci_lookup) + psdf = _lodf_pp_np_to_df(net, psdf_pp_np, branch_dict=branch_dict) else: - psdf = _LODF_pp_np_to_df(net, psdf_pp_np) + psdf_pp_np = _lodf_ppci_to_pp(net, psdf_ppci) + psdf = _lodf_pp_np_to_df(net, psdf_pp_np) + # Select only required data points according to the outage_branch_type if phase_shift_branch_type is not None: @@ -155,7 +156,7 @@ def _get_PSDF_direct( return psdf -def _get_PSDF_perturb( +def _get_psdf_perturb( net: pandapowerNet, phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, @@ -169,7 +170,7 @@ def _get_PSDF_perturb( raise NotImplementedError() -def run_PSDF( +def run_psdf( net: pandapowerNet, phase_shift_branch_type: str | None, phase_shift_branch_ix: ELE_IX_TYPE | None = None, @@ -211,7 +212,7 @@ def run_PSDF( logger.info("If a lot of branch required in psdf, please set perturb to False!") if recycle == "lodf" and distributed_slack == True: logger.warning("distributed_slack deactivated! recycling does not allow distributed slack") - psdf = _get_PSDF_perturb( + psdf = _get_psdf_perturb( net, phase_shift_branch_type=phase_shift_branch_type, phase_shift_branch_ix=phase_shift_branch_ix, @@ -221,7 +222,7 @@ def run_PSDF( else: if distributed_slack: logger.warning("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - psdf = _get_PSDF_direct( + psdf = _get_psdf_direct( net, phase_shift_branch_type=phase_shift_branch_type, phase_shift_branch_ix=phase_shift_branch_ix, diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index b0841ad17b..1aad42f097 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -def _get_PTDF_direct( +def _get_ptdf_direct( net: pandapowerNet, source_bus: int | npt.NDArray | None = None, result_side=0, @@ -50,19 +50,19 @@ def _get_PTDF_direct( else: reduced = False - ptdf_ppci, _ = _makePTDF_ppci( + ptdf_ppci, _ = _make_ptdf_ppci( net, using_sparse_solver=using_sparse_solver, result_side=result_side, branch_id=branch_id, reduced=reduced ) # Use lookup to convert ppci ptdf to pp if reduced: - ptdf_pp_np = _PTDF_ppci_to_pp(net, ptdf_ppci, result_side=result_side, branch_ppci_lookup=branch_ppci_lookup) + ptdf_pp_np = _ptdf_ppci_to_pp(net, ptdf_ppci, result_side=result_side, branch_ppci_lookup=branch_ppci_lookup) else: - ptdf_pp_np = _PTDF_ppci_to_pp(net, ptdf_ppci, result_side=result_side) + ptdf_pp_np = _ptdf_ppci_to_pp(net, ptdf_ppci, result_side=result_side) # Convert numpy array to pandas dataframe with the pp element index # All bus data points are available no definition of perturb bus needed - ptdf = _PTDF_pp_np_to_df(net, ptdf_pp_np, source_bus=None, branch_dict=branch_dict, reduced=reduced) + ptdf = _ptdf_pp_np_to_df(net, ptdf_pp_np, source_bus=None, branch_dict=branch_dict, reduced=reduced) # Select only required source buses source_bus = _get_source_bus_ix(net, source_bus) @@ -74,7 +74,7 @@ def _get_PTDF_direct( # Skip test if too few elements are calculated # Select three random buses and verify against perturb method verify_bus = np.random.choice(ptdf["line"].columns.values, 3, replace=False) - verify_PTDF( + verify_ptdf( net, source_bus=verify_bus, result_side=result_side, @@ -83,7 +83,7 @@ def _get_PTDF_direct( return ptdf -def _get_PTDF_perturb( +def _get_ptdf_perturb( net: pandapowerNet, source_bus: int | npt.NDArray | None = None, result_side: int = 0, @@ -99,7 +99,7 @@ def _get_PTDF_perturb( source_bus = _get_source_bus_ix(net, source_bus) # Init ptdf numpy array - ptdf_pp_np = _init_PTDF_pp_np(net, source_bus.shape[0]) + ptdf_pp_np = _init_ptdf_pp_np(net, source_bus.shape[0]) rundcpp(net, distributed_slack=distributed_slack) # Using new net_mod object to do perturb @@ -124,11 +124,11 @@ def _get_PTDF_perturb( ) # Convert numpy array to pandas dataframe with the pp element index - ptdf = _PTDF_pp_np_to_df(net, ptdf_pp_np, source_bus=source_bus) + ptdf = _ptdf_pp_np_to_df(net, ptdf_pp_np, source_bus=source_bus) return ptdf -def _get_dc_profile_with_PTDF(net, profiles, result_side=0, ptdf=None): +def _get_dc_profile_with_ptdf(net, profiles, result_side=0, ptdf=None): """ Run dc profile with ptdf method, if ptdf not given will be recalculated :return: {branch_type ("line", "trafo", "impedance", "trafo3w_{hv,mv,lv}"): @@ -139,7 +139,7 @@ def _get_dc_profile_with_PTDF(net, profiles, result_side=0, ptdf=None): slack_df, _ = get_dist_slack(net, pf_required=True) if ptdf is None: - ptdf = _get_PTDF_direct(net, result_side=result_side) + ptdf = _get_ptdf_direct(net, result_side=result_side) net_mod = deepcopy(net) num_calc = None @@ -218,7 +218,7 @@ def _get_dc_profile_with_PTDF(net, profiles, result_side=0, ptdf=None): # Convert data in numpy array to pandas dataframe with pp index -def _PTDF_pp_np_to_df(net, res_pp, source_bus=None, nan_to_num=True, branch_dict=None, reduced=False): +def _ptdf_pp_np_to_df(net, res_pp, source_bus=None, nan_to_num=True, branch_dict=None, reduced=False): res = {} for br_type, data in res_pp.items(): if nan_to_num: @@ -244,7 +244,7 @@ def _PTDF_pp_np_to_df(net, res_pp, source_bus=None, nan_to_num=True, branch_dict # Init result numpy array filled with zeros -def _init_PTDF_pp_np(net, num_source_bus): +def _init_ptdf_pp_np(net, num_source_bus): ptdf_pp = {} for br_type in ("line", "dcline", "trafo", "impedance"): if not net[br_type].empty: @@ -255,14 +255,14 @@ def _init_PTDF_pp_np(net, num_source_bus): return ptdf_pp -def _makePTDF_ppci(net, using_sparse_solver, result_side, branch_id=None, reduced=False): +def _make_ptdf_ppci(net, using_sparse_solver, result_side, branch_id=None, reduced=False): # Select subnet areas slack_df, pp_area_bus_mapping = get_dist_slack(net) _, ppci = _pd2ppc(net) # Make PTDF of the ppci data stucture ppci_slack_mask_with_prio = get_ppci_dist_slack(net, ppci, slack_df) if len(pp_area_bus_mapping) > 1: - ptdf_ppci = makePTDF_multi_area( + ptdf_ppci = make_ptdf_multi_area( net, ppci, pp_area_bus_mapping, @@ -284,7 +284,7 @@ def _makePTDF_ppci(net, using_sparse_solver, result_side, branch_id=None, reduce return ptdf_ppci, ppci -def _PTDF_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): +def _ptdf_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): # Padding the sensitivity matrix for out-of-service elements ptdf_ppci_padding = np.pad(ptdf_ppci, ((0, 1), (0, 1)), mode="constant", constant_values=DISCONNECTED_PADDING_VALUE) @@ -328,7 +328,7 @@ def _PTDF_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): return results -def run_PTDF( +def run_ptdf( net: pandapowerNet, source_bus: int | npt.NDArray | None = None, distributed_slack: bool = True, @@ -369,11 +369,11 @@ def run_PTDF( if perturb: # or not distributed_slack: # if not distributed_slack: # logger.info("distributed_slack deactivated! Distirbuted slacks are used as Vref! Only Perturb Possible") - ptdf = _get_PTDF_perturb( + ptdf = _get_ptdf_perturb( net, source_bus=source_bus, result_side=result_side, distributed_slack=distributed_slack ) else: - ptdf = _get_PTDF_direct( + ptdf = _get_ptdf_direct( net, source_bus=source_bus, result_side=result_side, @@ -392,7 +392,7 @@ def run_PTDF( return ptdf -def verify_PTDF( +def verify_ptdf( net: pandapowerNet, source_bus: ELE_IX_TYPE | None = None, result_side: int = 0, @@ -405,7 +405,7 @@ def verify_PTDF( net = deepcopy(net) # ToDo: Verify what the distributed_slack options does for both functions (perturb and classic) if ptdf is None: - ptdf = run_PTDF( + ptdf = run_ptdf( net, source_bus=source_bus, result_side=result_side, @@ -414,7 +414,7 @@ def verify_PTDF( random_verify=False, distributed_slack=False, ) - ptdf_perturb = run_PTDF(net, source_bus=source_bus, result_side=result_side, distributed_slack=False, perturb=True) + ptdf_perturb = run_ptdf(net, source_bus=source_bus, result_side=result_side, distributed_slack=False, perturb=True) assert len(ptdf) > 0, "Empty ptdf, verification not possible!" for key in ptdf.keys(): @@ -425,16 +425,16 @@ def verify_PTDF( logger.info("All PTDF results verified with perturb method!") -def verify_dc_profile_with_PTDF(net, profiles: dict, result_side=0, ptdf=None): +def verify_dc_profile_with_ptdf(net, profiles: dict, result_side=0, ptdf=None): """ this function verifies the result of run profile with PTDF and perturb method, raise AssertionError on mismatches! """ # ToDo: Verify what the distributed_slack options does for both functions (perturb and classic) - res_profile_ptdf = run_dc_profile( + res_profile_ptdf = run_ptdf_dc_profile( net, profiles, result_side=result_side, perturb=False, ptdf=ptdf, distributed_slack=False ) - res_profile_perturb = run_dc_profile(net, profiles, result_side=result_side, distributed_slack=False, perturb=True) + res_profile_perturb = run_ptdf_dc_profile(net, profiles, result_side=result_side, distributed_slack=False, perturb=True) assert len(res_profile_ptdf) > 0, "Empty result profile, verification not possible!" for key in res_profile_ptdf.keys(): @@ -443,9 +443,9 @@ def verify_dc_profile_with_PTDF(net, profiles: dict, result_side=0, ptdf=None): logger.info("Run dc profile with PTDF verified!") -def makePTDF_multi_area(net, ppci, - pp_area_bus_mapping, ppci_slack_mask_with_prio, - using_sparse_solver, result_side): +def make_ptdf_multi_area(net, ppci, + pp_area_bus_mapping, ppci_slack_mask_with_prio, + using_sparse_solver, result_side): """ Select areas in the ppci network and calculate ptdf of each area independently """ ptdf_ppci = np.zeros((ppci["branch"].shape[0], ppci["bus"].shape[0]), dtype=float) @@ -555,7 +555,7 @@ def _get_dc_profile_perturb(net, profiles, result_side=0, distributed_slack=True # All functions should be called from external -def run_dc_profile( +def run_ptdf_dc_profile( net, profiles: dict, result_side=0, @@ -597,7 +597,7 @@ def run_dc_profile( extra_data_points=extra_data_points, ) else: - res = _get_dc_profile_with_PTDF(net, profiles, result_side=result_side, ptdf=ptdf) + res = _get_dc_profile_with_ptdf(net, profiles, result_side=result_side, ptdf=ptdf) res_renamed = {} THIS_RES_BR_SIDE_MAPPING = BR_SIDE_MAPPING if result_side == 0 else BR_SIDE_MAPPING_1 diff --git a/pandapower/analysis/__init__.py b/pandapower/analysis/__init__.py index e69de29bb2..44c81f201d 100644 --- a/pandapower/analysis/__init__.py +++ b/pandapower/analysis/__init__.py @@ -0,0 +1,4 @@ +from pandapower.analysis.LODF import run_lodf, run_lodf_dc_n1, verify_dc_n1_with_lodf, verify_lodf +from pandapower.analysis.PSDF import run_psdf, make_psdf +from pandapower.analysis.PTDF import (run_ptdf, verify_ptdf, verify_dc_profile_with_ptdf, make_ptdf_multi_area, + run_ptdf_dc_profile) \ No newline at end of file diff --git a/pandapower/test/analysis/test_distribution_factors.py b/pandapower/test/analysis/test_distribution_factors.py index 8b9eee971b..729136c451 100644 --- a/pandapower/test/analysis/test_distribution_factors.py +++ b/pandapower/test/analysis/test_distribution_factors.py @@ -4,9 +4,9 @@ import copy from pandapower import pandapowerNet from pandapower.run import rundcpp -from pandapower.analysis.PTDF import run_PTDF, verify_dc_profile_with_PTDF -from pandapower.analysis.LODF import run_LODF, verify_dc_n1_with_LODF -from pandapower.analysis.PTDF import run_dc_profile +from pandapower.analysis.PTDF import run_ptdf, verify_dc_profile_with_ptdf +from pandapower.analysis.LODF import run_lodf, verify_dc_n1_with_lodf +from pandapower.analysis.PTDF import run_ptdf_dc_profile from pandapower.networks.power_system_test_cases import ( case30, case118, @@ -70,8 +70,8 @@ def profiles(): def test_lodf(net_in): net, lodf_line = net_in outage_branch = lodf_line - lodf_matrix = run_LODF(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=False, random_verify=False) - lodf_perturb = run_LODF(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=True) + lodf_matrix = run_lodf(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=False, random_verify=False) + lodf_perturb = run_lodf(net, outage_branch_type="line", outage_branch_ix=outage_branch, perturb=True) lodf_comp_df = pd.DataFrame( data={ "matrix": lodf_matrix[("line", "line")].loc[:, outage_branch], @@ -110,16 +110,16 @@ def test_random_outage_of_element(): def test_trafo3w(): # Example net with trafo3w net = example_multivoltage() - ptdf_t3w = run_PTDF(net) - lodf_t3w = run_LODF(net, outage_branch_type="line") + ptdf_t3w = run_ptdf(net) + lodf_t3w = run_lodf(net, outage_branch_type="line") def test_profile_multiple_elements(profiles): # Example run profile of multiple element types net = case118() - res_profiles_ptdf = run_dc_profile(net, profiles=profiles) - res_profiles_full = run_dc_profile(net, profiles=profiles, extra_data_points=[("bus", "va_degree")]) - verify_dc_profile_with_PTDF(net, profiles) + res_profiles_ptdf = run_ptdf_dc_profile(net, profiles=profiles) + res_profiles_full = run_ptdf_dc_profile(net, profiles=profiles, extra_data_points=[("bus", "va_degree")]) + verify_dc_profile_with_ptdf(net, profiles) def test_run_selected_elements(profiles): @@ -135,9 +135,9 @@ def test_run_selected_elements(profiles): ) profiles_partial[("load", "p_mw")] *= np.random.rand(*profiles_partial[("load", "p_mw")].shape) - res_profiles_partial = run_dc_profile(net, profiles_partial) - verify_dc_profile_with_PTDF(net, profiles=profiles, result_side=1) - verify_dc_n1_with_LODF(net, outage_branch_type="line") + res_profiles_partial = run_ptdf_dc_profile(net, profiles_partial) + verify_dc_profile_with_ptdf(net, profiles=profiles, result_side=1) + verify_dc_n1_with_lodf(net, outage_branch_type="line") if __name__ == "__main__": From b2fc35ce12d81bef3ea422fc038c56da1e7d8321 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 10 Apr 2026 08:35:38 +0200 Subject: [PATCH 11/15] some codacy fixes. --- pandapower/analysis/LODF.py | 4 ++-- pandapower/analysis/PSDF.py | 2 +- pandapower/analysis/utils.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 91e9d5438b..555b482376 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -528,8 +528,8 @@ def verify_lodf( assert len(lodf) > 0, "Empty lodf, verification not possible!" for key in lodf.keys(): - filter = ~lodf[key].isna().any(axis=0) - assert np.allclose(lodf[key].loc[:, filter], lodf_perturb[key].loc[:, filter], atol=1e-8, equal_nan=True), ( + filter_lodf = ~lodf[key].isna().any(axis=0) + assert np.allclose(lodf[key].loc[:, filter_lodf], lodf_perturb[key].loc[:, filter_lodf], atol=1e-8, equal_nan=True), ( f"{key} LODF results verification failed!" ) logger.info(str(key) + " LODF results verified!") diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index 4dffef916a..582a4ae731 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -62,7 +62,7 @@ def make_psdf( ## constants nb = bus.shape[0] ## number of buses nl = branch.shape[0] ## number of lines - noref = arange(1, nb) ## use bus 1 for voltage angle reference + # noref = arange(1, nb) ## use bus 1 for voltage angle reference noslack = find(arange(nb) != slack_bus) ## build connection matrix Cft = Cf - Ct for line and from - to buses diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index c6b40810bf..81c0c9db0f 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -150,9 +150,9 @@ def branch_dict_to_ppci_branch_list( if br_type == "trafo3w": trafo3w_lookup = _get_trafo3w_lookup(net) if trafo3w_lookup is not None: - for type in ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"]: - branch_id_ppci += list(trafo3w_lookup[type][branch_id]) - ppci_branch_lookup[type] = [s, t] + for trafo_type in ["trafo3w_hv", "trafo3w_mv", "trafo3w_lv"]: + branch_id_ppci += list(trafo3w_lookup[trafo_type][branch_id]) + ppci_branch_lookup[trafo_type] = [s, t] s = t t += len(branch_id) else: From 71a0446a56b00d140235c5b6a209e8586fb4058e Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Fri, 10 Apr 2026 09:06:03 +0200 Subject: [PATCH 12/15] some sonarcloud fixes. --- pandapower/analysis/LODF.py | 5 +++-- pandapower/analysis/PSDF.py | 4 ++-- pandapower/analysis/PTDF.py | 5 +++-- pandapower/test/analysis/test_distribution_factors.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 555b482376..5e541f77b8 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -96,7 +96,8 @@ def _get_lodf_direct( if random_verify and outage_branch_ix.size >= 3: # Skip test if too few elements are calculated # Select three random branches and verify against perturb method - verify_branch_ix = np.random.choice(outage_branch_ix, 3) + rng = np.random.default_rng() + verify_branch_ix = rng.choice(a=outage_branch_ix, size=3) verify_lodf( net, outage_branch_type=outage_branch_type, @@ -151,7 +152,7 @@ def _lodf_ppci_to_pp( lodf_ppci_padding = np.pad(lodf_ppci, ((0, 1), (0, 1)), mode="constant", constant_values=DISCONNECTED_PADDING_VALUE) - results = dict() + results = {} available_branch_types = [br_type for br_type, lookup in pp_ppci_branch_lookups.items() if lookup is not None] for goal_br_type, source_br_type in product(available_branch_types, repeat=2): diff --git a/pandapower/analysis/PSDF.py b/pandapower/analysis/PSDF.py index 582a4ae731..e99614cff5 100644 --- a/pandapower/analysis/PSDF.py +++ b/pandapower/analysis/PSDF.py @@ -26,7 +26,7 @@ def make_psdf( baseMVA: float, - PTDF: npt.NDArray, + ptdf: npt.NDArray, bus: npt.NDArray, branch: npt.NDArray, using_sparse_solver: bool = False, @@ -81,7 +81,7 @@ def make_psdf( Bd = sp.sparse.diags(b.real) - PSDF = Bd - PTDF[:, noslack] * (Cft.T * Bd) + PSDF = Bd - ptdf[:, noslack] * (Cft.T * Bd) PSDF = PSDF * (pi / 180 * baseMVA) return PSDF diff --git a/pandapower/analysis/PTDF.py b/pandapower/analysis/PTDF.py index 1aad42f097..c0dbb82469 100644 --- a/pandapower/analysis/PTDF.py +++ b/pandapower/analysis/PTDF.py @@ -73,7 +73,8 @@ def _get_ptdf_direct( if random_verify and source_bus.size >= 3: # Skip test if too few elements are calculated # Select three random buses and verify against perturb method - verify_bus = np.random.choice(ptdf["line"].columns.values, 3, replace=False) + rng = np.random.default_rng() + verify_bus = rng.choice(ptdf["line"].columns.values, size=3, replace=False) verify_ptdf( net, source_bus=verify_bus, @@ -291,7 +292,7 @@ def _ptdf_ppci_to_pp(net, ptdf_ppci, result_side, branch_ppci_lookup=None): # Get bus pp ppci lookup pp_ppci_bus_lookup = _get_bus_lookup(net) - results = dict() + results = {} # Get branch pp ppci lookup and update the matrix for br_type in ("line", "trafo", "impedance"): if branch_ppci_lookup is not None: diff --git a/pandapower/test/analysis/test_distribution_factors.py b/pandapower/test/analysis/test_distribution_factors.py index 729136c451..240759ce73 100644 --- a/pandapower/test/analysis/test_distribution_factors.py +++ b/pandapower/test/analysis/test_distribution_factors.py @@ -125,7 +125,7 @@ def test_profile_multiple_elements(profiles): def test_run_selected_elements(profiles): # Example run profile simulation of only selected elements net = case118() - profiles_partial = dict() + profiles_partial = {} num_calc = 100 load_ix = [2, 3, 5] profiles_partial[("load", "p_mw")] = pd.DataFrame( From f908836a9be94d57277905a4f3532126a1e02a04 Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Mon, 13 Apr 2026 12:55:42 +0200 Subject: [PATCH 13/15] wrong datatype. --- pandapower/analysis/LODF.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandapower/analysis/LODF.py b/pandapower/analysis/LODF.py index 5e541f77b8..c7a0ccd41d 100644 --- a/pandapower/analysis/LODF.py +++ b/pandapower/analysis/LODF.py @@ -255,7 +255,7 @@ def _get_lodf_perturb( # If the branch flow close to zero # Fix low loading branch with ptdf this_ptdf = _get_ptdf_perturb( - net_mod, source_bus=[bus_0, bus_1], distributed_slack=distributed_slack + net_mod, source_bus=np.array([bus_0, bus_1]), distributed_slack=distributed_slack ) # distributed slack is default True if np.abs(this_ptdf[outage_branch_type + BR_PTDF_MAPPING[outage_branch_type]].at[br_ix, bus_0]) > 0.1: bus_to_add_load = bus_0 From 8cd2760a93b0aeee3aed52c0936d5c5f2792be0b Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Mon, 13 Apr 2026 13:00:03 +0200 Subject: [PATCH 14/15] removed unnecessary check. --- pandapower/analysis/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandapower/analysis/utils.py b/pandapower/analysis/utils.py index 81c0c9db0f..1bdb914b19 100644 --- a/pandapower/analysis/utils.py +++ b/pandapower/analysis/utils.py @@ -76,12 +76,12 @@ def _get_outage_branch_ix( return unique_outage_branch_ix if unique_outage_branch_ix.size < outage_branch_ix.size else outage_branch_ix -def _get_bus_lookup(net: pandapowerNet) -> np.ndarray: +def _get_bus_lookup(net: pandapowerNet) -> npt.NDArray: pp_ppci_bus_lookup = net._pd2ppc_lookups["bus"] # Set out-of-service bus index to -1 (for padded array) if "_is_elements" not in net or net._is_elements is None: raise UserWarning("can not lookup bus, net._is_elements is missing or None") - assert type(net._is_elements) is pd.DataFrame # force mypy type narrowing + bus_in_service_mask = np.isin(np.arange(pp_ppci_bus_lookup.shape[0]), net._is_elements["bus_is_idx"]) pp_ppci_bus_lookup[~bus_in_service_mask] = -1 return pp_ppci_bus_lookup From 988833b82d5c9e7aba962c8ebff930b35b975dbf Mon Sep 17 00:00:00 2001 From: Mike Vogt Date: Mon, 13 Apr 2026 14:34:29 +0200 Subject: [PATCH 15/15] added a changelog entry. --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb5af3dc70..777c7f59f2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,7 @@ Change Log [upcoming release] - 2026-..-.. ------------------------------- +- [ADDED] added an analysis package, it provides LODF, PSDF and PTDF calculation - [FIXED] runopp(init="results") now preserves the warm-start vector in the PIPS-backed AC OPF solver - [ADDED] added more functions to diagnostic - [ADDED] check to check if vkr_percent values are reasonable (see issue #786).