From f7d9b7879bfc3c1aa03e8313529202f8f3cce9a8 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Thu, 7 Nov 2024 17:23:44 +0100 Subject: [PATCH 01/15] jao converter --- CHANGELOG.rst | 1 + pandapower/converter/__init__.py | 1 + pandapower/converter/jao/__init__.py | 1 + pandapower/converter/jao/from_jao.py | 987 ++++++++++++++++++ .../converter/jao_testfiles/testfile.xlsx | Bin 0 -> 76698 bytes pandapower/test/converter/test_from_jao.py | 60 ++ 6 files changed, 1050 insertions(+) create mode 100644 pandapower/converter/jao/__init__.py create mode 100644 pandapower/converter/jao/from_jao.py create mode 100644 pandapower/test/converter/jao_testfiles/testfile.xlsx create mode 100644 pandapower/test/converter/test_from_jao.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98977dd20..a4dcba2a9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -88,6 +88,7 @@ Change Log - [CHANGED] Trafo Controllers can now be added to elements that are out of service, changed self.nothing_to_do() - [ADDED] Discrete shunt controller for local voltage regulation with shunt steps - [ADDED] fix lengths missmatch of output if ignore_zero_length is False in plotting utility function coords_from_node_geodata() and rename ignore_zero_length by ignore_no_geo_diff +- [ADDED] converter for European EHV grid data from JAO, the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that operate in accordance to EU legislation" - [ADDED] cim2pp converter: Using lxml to parse XML files (better performance) [2.14.7] - 2024-06-14 diff --git a/pandapower/converter/__init__.py b/pandapower/converter/__init__.py index acdf19579..5df505f2a 100644 --- a/pandapower/converter/__init__.py +++ b/pandapower/converter/__init__.py @@ -3,3 +3,4 @@ from pandapower.converter.pandamodels import * from pandapower.converter.cim import * from pandapower.converter.powerfactory import * +from pandapower.converter.jao import * diff --git a/pandapower/converter/jao/__init__.py b/pandapower/converter/jao/__init__.py new file mode 100644 index 000000000..2aae42afa --- /dev/null +++ b/pandapower/converter/jao/__init__.py @@ -0,0 +1 @@ +from .from_jao import from_jao \ No newline at end of file diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py new file mode 100644 index 000000000..9ce6b05bf --- /dev/null +++ b/pandapower/converter/jao/from_jao.py @@ -0,0 +1,987 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2024 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + +from copy import deepcopy +import os +import json +from functools import reduce +import numpy as np +import pandas as pd +from pandas.api.types import is_integer_dtype, is_object_dtype +from pandapower.io_utils import pandapowerNet +from pandapower.create import create_empty_network, create_buses, create_lines_from_parameters, \ + create_transformers_from_parameters +from pandapower.topology import create_nxgraph, connected_components +from pandapower.plotting import set_line_geodata_from_bus_geodata +from pandapower.toolbox import drop_buses, fuse_buses + +try: + import pandaplan.core.pplog as logging +except ImportError: + import logging + +logger = logging.getLogger(__name__) + + +def from_jao(excel_file_path:str, + html_file_path: str|None, + extend_data_for_grid_group_connections: bool, + drop_grid_groups_islands: bool = False, + apply_data_correction: bool = True, + **kwargs) -> pandapowerNet: + """Converts European (Core) EHV grid data provided by JAO, the "Single Allocation Platform (SAP) + for all European Transmission System Operators (TSOs) that operate in accordance to EU + legislation". At least in November 2024, the data are available at the website + https://www.jao.eu/static-grid-model . There, a map is provided to get an fine overview of the + geographical extent and the scope of the data. These inlcude information about European (Core) + lines, tielines, and transformers. No information is available on load or generation. + The data quality with regard to the interconnection of the equipment, the information provided + and the (incomplete) geodata should be considered with caution. + + Parameters + ---------- + excel_file_path : str + input data including electrical parameters of grids' utilities, stored in multiple sheets + of an excel file + html_file_path : str + input data for geo information. If The converter should be run without geo information, None + can be passed., provided by an html file + extend_data_for_grid_group_connections : bool + if True, connections (additional transformers and merging buses) are created to avoid + islanded grid groups, by default False + drop_grid_groups_islands : bool, optional + if True, islanded grid groups will be dropped if their number of buses is below + min_bus_number (default is 6), by default False + apply_data_correction : bool, optional + _description_, by default True + + Returns + ------- + pandapowerNet + _description_ + + Additional Parameters + --------------------- + minimal_trafo_invention : bool, optional + applies if extend_data_for_grid_group_connections is True. Then, if minimal_trafo_invention + is True, adding transformers stops when no grid groups is islanded anymore (does not apply + for release version 5 or 6, i.e. it does not care what value is passed to + minimal_trafo_invention). If False, all equally named buses that have different voltage + level and lay in different groups will be connected via additional transformers, + by default False + min_bus_number : int|str, optional + Threshold value to decide which small grid groups should be dropped and which large grid + groups should be kept. If all islanded grid groups should be dropped except of the one + largest, set "max". If all grid groups that do not contain a slack element should be + dropped, set "unsupplied". By default 6 + rel_deviation_threshold_for_trafo_bus_creation : float, optional + If the voltage level of transformer locations is far different than the transformer data, + additional buses are created. rel_deviation_threshold_for_trafo_bus_creation defines the + tolerance in which no additional buses are created. By default 0.2 + log_rel_vn_deviation : float, optional + This parameter allows a range below rel_deviation_threshold_for_trafo_bus_creation in which + a warning is logged instead of a creating additional buses. By default 0.12 + + """ + + # --- read data + data = pd.read_excel(excel_file_path, sheet_name=None, header=[0, 1]) + if html_file_path is not None: + with open(html_file_path, mode='r', encoding=kwargs.get("encoding", "utf-8")) as f: + html_str = f.read() + else: + html_str = "" + + # --- manipulate data / data corrections + if apply_data_correction: + html_str = _data_correction(data, html_str) + + # --- parse html_str to line_geo_data + line_geo_data = None + if html_str: + try: + line_geo_data = _parse_html_str(html_str) + except (json.JSONDecodeError, KeyError, AssertionError) as e: + logger.error(f"html data were ignored due to this error:\n{e}") + + # --- create the pandapower net + net = create_empty_network(name=os.path.splitext(os.path.basename(excel_file_path))[0], + **{key: val for key, val in kwargs.items() if key == "sn_mva"}) + _create_buses_from_line_data(net, data) + _create_lines(net, data) + _create_transformers_and_buses(net, data, **kwargs) + + # --- invent connections between grid groups + if extend_data_for_grid_group_connections: + _invent_connections_between_grid_groups(net, **kwargs) + + # --- drop islanded grid groups + if drop_grid_groups_islands: + drop_islanded_grid_groups(net, kwargs.get("min_bus_number", 6)) + + # --- add geo data to buses and lines + if line_geo_data is not None: + _add_bus_geo(net, line_geo_data) + set_line_geodata_from_bus_geodata(net) + + return net + +# --- secondary functions -------------------------------------------------------------------------- + + +def _data_correction(data:dict[str, pd.DataFrame], html_str:str|None) -> str|None: + """Corrects input data in particular with regard to obvious weaknesses in the data provided, + such as inconsistent spellings and missing necessary information + + Parameters + ---------- + data : dict[str, pd.DataFrame] + data provided by the excel file which will be corrected + html_str : str | None + data provided by the html file which will be corrected + + Returns + ------- + str + corrected html_str + """ + + # --- Line and Tieline data --------------------------- + for key in ["Lines", "Tielines"]: + + # --- correct column names + cols = data[key].columns.to_frame().reset_index(drop=True) + cols.loc[cols[1] == "Voltage_level(kV)", 0] = None + cols.loc[cols[1] == "Comment", 0] = None + cols.loc[cols[0].str.startswith("Unnamed:").astype(bool), 0] = None + cols.loc[cols[1] == "Length_(km)", 0] = "Electrical Parameters" # might be wrong in + # Tielines otherwise + data[key].columns = pd.MultiIndex.from_arrays(cols.values.T) + + # --- correct comma separation and cast to floats + data[key][("Maximum Current Imax (A)", "Fixed")] = \ + data[key][("Maximum Current Imax (A)", "Fixed")].replace("\xa0", 999e3).replace( + "-", 999e3).replace(" ", 999e3) + col_names = [("Electrical Parameters", col_level1) for col_level1 in [ + "Length_(km)", "Resistance_R(Ω)", "Reactance_X(Ω)", "Susceptance_B(μS)", + "Length_(km)"]] + [("Maximum Current Imax (A)", "Fixed")] + _float_col_comma_correction(data, key, col_names) + + # --- consolidate to one way of name capitalization + for loc_name in [(None, "NE_name"), ("Substation_1", "Full_name"), + ("Substation_2", "Full_name")]: + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace( + "STANISLAWOW", "Stanislawow").str.replace("VIERRADEN", "Vierraden") + html_str = html_str.replace("STANISLAWOW", "Stanislawow").replace("Chelm", "CHELM") + + # --- Transformer data -------------------------------- + key = "Transformers" + + # --- fix Locations + loc_name = ("Location", "Full Name") + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace( + "PSTMIKULOWA", "PST MIKULOWA").str.replace( + "Chelm", "CHELM").str.replace( + "OLSZTYN-MATK", "OLSZTYN-MATKI").str.replace( + "STANISLAWOW", "Stanislawow").str.replace("VIERRADEN", "Vierraden") + + # --- fix data in nonnull_taps + taps = data[key].loc[:, ("Phase Shifting Properties", "Taps used for RAO")].fillna("").astype( + str).str.replace(" ", "") + nonnull = taps.apply(len).astype(bool) + nonnull_taps = taps.loc[nonnull] + surrounded = nonnull_taps.str.startswith("<") & nonnull_taps.str.endswith(">") + nonnull_taps.loc[surrounded] = nonnull_taps.loc[surrounded].str[1:-1] + slash_sep = (~nonnull_taps.str.contains(";")) & nonnull_taps.str.contains("/") + nonnull_taps.loc[slash_sep] = nonnull_taps.loc[slash_sep].str.replace("/", ";") + nonnull_taps.loc[nonnull_taps == "0"] = "0;0" + data[key].loc[nonnull, ("Phase Shifting Properties", "Taps used for RAO")] = nonnull_taps + data[key].loc[~nonnull, ("Phase Shifting Properties", "Taps used for RAO")] = "0;0" + + # --- phase shifter with double info + cols = ["Phase Regulation δu (%)", "Angle Regulation δu (%)"] + for col in cols: + if is_object_dtype(data[key].loc[:, ("Phase Shifting Properties", col)]): + tr_double = data[key].index[data[key].loc[:, ( + "Phase Shifting Properties", col)].str.contains("/").fillna(0).astype(bool)] + data[key].loc[tr_double, ("Phase Shifting Properties", col)] = data[key].loc[tr_double, + ("Phase Shifting Properties", col)].str.split("/", expand=True)[1].str.replace( + ",", ".").astype(float).values # take second info and correct separation: , -> . + + return html_str + + +def _parse_html_str(html_str:str) -> pd.DataFrame: + """Converts ths geodata from the html file (information hidden in the string), from Lines in + particular, to a DataFrame that can be used later in _add_bus_geo() + + Parameters + ---------- + html_str : str + html file that includes geodata information + + Returns + ------- + pd.DataFrame + extracted geodata for a later and easy use + """ + def _filter_name(st:str) -> str: + name_start = "NE name: " + name_end = "" + pos0 = st.find(name_start) + len(name_start) + pos1 = st.find(name_end, pos0) + assert pos0 >= 0 + assert pos1 >= len(name_start) + return st[pos0:pos1] + + json_start_str = '') + json_str = html_str[json_start_pos:(json_start_pos+json_end_pos)] + geo_data = json.loads(json_str) + geo_data = geo_data["x"]["calls"] + methods_pos = pd.Series({item["method"]: i for i, item in enumerate(geo_data)}) + polylines = geo_data[methods_pos.at["addPolylines"]]["args"] + EIC_start = "EIC Code: " + if len(polylines[6]) != len(polylines[0]): + raise AssertionError("The lists of EIC Code data and geo data are not of the same length.") + line_EIC = [polylines[6][i][polylines[6][i].find(EIC_start)+len(EIC_start):] for i in range( + len(polylines[6]))] + line_name = [_filter_name(polylines[6][i]) for i in range(len(polylines[6]))] + line_geo_data = pd.concat([_lng_lat_to_df(polylines[0][i][0][0], line_EIC[i], line_name[i]) for + i in range(len(polylines[0]))], ignore_index=True) + + # remove trailing whitespaces + for col in ["EIC_Code", "name"]: + line_geo_data[col] = line_geo_data[col].str.strip() + + return line_geo_data + + +def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: + """Creates buses to the pandapower net using information from the lines and tielines sheets + (excel file). + + Parameters + ---------- + net : pandapowerNet + net to be filled by buses + data : dict[str, pd.DataFrame] + data provided by the excel file which will be corrected + """ + bus_df_empty = pd.DataFrame({"name": str(), "vn_kv": float(), "TSO": str()}, index=[]) + bus_df = deepcopy(bus_df_empty) + for key in ["Lines", "Tielines"]: + for subst in ['Substation_1', 'Substation_2']: + data_col_tuples = [(subst, "Full_name"), (None, "Voltage_level(kV)"), (None, "TSO")] + to_add = data[key].loc[:, data_col_tuples].set_axis(bus_df.columns, axis="columns") + if len(bus_df): + bus_df = pd.concat([bus_df, to_add]) + else: + bus_df = to_add + bus_df = _drop_duplicates_and_join_TSO(bus_df) + new_bus_idx = create_buses( + net, len(bus_df), vn_kv=bus_df.vn_kv, name=bus_df.name, zone=bus_df.TSO) + assert np.allclose(new_bus_idx, bus_df.index) + + +def _create_lines(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: + """Creates lines to the pandapower net using information from the lines and tielines sheets + (excel file). + + Parameters + ---------- + net : pandapowerNet + net to be filled by buses + data : dict[str, pd.DataFrame] + data provided by the excel file which will be corrected + """ + + bus_idx = _get_bus_idx(net) + + for key in ["Lines", "Tielines"]: + length_km = data[key][("Electrical Parameters", "Length_(km)")].values + zero_length = np.isclose(length_km, 0) + no_length = np.isnan(length_km) + if sum(zero_length) or sum(no_length): + logger.warning(f"According to given data, {sum(zero_length)} {key.lower()} have zero " + f"length and {sum(zero_length)} {key.lower()} have no length data. " + "Both types of wrong data are replaced by 1 km.") + length_km[zero_length | no_length] = 1 + vn_kvs = data[key].loc[:, (None, "Voltage_level(kV)")].values + + _ = create_lines_from_parameters( + net, + bus_idx.loc[list(tuple(zip(data[key].loc[:, ("Substation_1", "Full_name")].values, + vn_kvs)))].values, + bus_idx.loc[list(tuple(zip(data[key].loc[:, ("Substation_2", "Full_name")].values, + vn_kvs)))].values, + length_km, + data[key][("Electrical Parameters", "Resistance_R(Ω)")].values / length_km, + data[key][("Electrical Parameters", "Reactance_X(Ω)")].values / length_km, + data[key][("Electrical Parameters", "Susceptance_B(μS)")].values / length_km, + data[key][("Maximum Current Imax (A)", "Fixed")].fillna(999000).values / 1e3, + name=data[key].xs("NE_name", level=1, axis=1).values[:, 0], + EIC_Code=data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], + TSO=data[key].xs("TSO", level=1, axis=1).values[:, 0], + Comment=data[key].xs("Comment", level=1, axis=1).values[:, 0], + Tieline=key=="Tielines", + ) + + +def _create_transformers_and_buses( + net:pandapowerNet, data:dict[str, pd.DataFrame], **kwargs) -> None: + """Creates transformers to the pandapower net using information from the transformers sheet + (excel file). + + Parameters + ---------- + net : pandapowerNet + net to be filled by buses + data : dict[str, pd.DataFrame] + data provided by the excel file which will be corrected + """ + + # --- data preparations + key = "Transformers" + bus_idx = _get_bus_idx(net) + vn_hv_kv, vn_lv_kv = _get_transformer_voltages(data, bus_idx) + trafo_connections = _allocate_trafos_to_buses_and_create_buses( + net, data, bus_idx, vn_hv_kv, vn_lv_kv, **kwargs) + max_i_a = data[key].loc[:, ("Maximum Current Imax (A) primary", "Fixed")] + empty_i_idx = max_i_a.index[max_i_a.isnull()] + max_i_a.loc[empty_i_idx] = data[key].loc[empty_i_idx, ( + "Maximum Current Imax (A) primary", "Max")].values + sn_mva = np.sqrt(3) * max_i_a * vn_hv_kv / 1e3 + z_pu = vn_lv_kv**2 / sn_mva + rk = data[key].xs("Resistance_R(Ω)", level=1, axis=1).values[:, 0] / z_pu + xk = data[key].xs("Reactance_X(Ω)", level=1, axis=1).values[:, 0] / z_pu + b0 = data[key].xs("Susceptance_B (µS)", level=1, axis=1).values[:, 0] * 1e-6 * z_pu + g0 = data[key].xs("Conductance_G (µS)", level=1, axis=1).values[:, 0] * 1e-6 * z_pu + zk = np.sqrt(rk**2 + xk**2) + vk_percent = np.sign(xk) * zk * 100 + vkr_percent = rk * 100 + pfe_kw = g0 * sn_mva * 1e3 + i0_percent = 100 * np.sqrt(b0**2 + g0**2) * net.sn_mva / sn_mva + taps = data[key].loc[:, ("Phase Shifting Properties", "Taps used for RAO")].str.split( + ";", expand=True).astype(int).set_axis(["tap_min", "tap_max"], axis=1) + + du = _get_float_column(data[key], ("Phase Shifting Properties", "Phase Regulation δu (%)")) + dphi = _get_float_column(data[key], ("Phase Shifting Properties", "Angle Regulation δu (%)")) + phase_shifter = np.isclose(du, 0) & (~np.isclose(dphi, 0)) # Symmetrical/Asymmetrical not + # considered + + _ = create_transformers_from_parameters( + net, + trafo_connections.hv_bus.values, + trafo_connections.lv_bus.values, + sn_mva, + vn_hv_kv, + vn_lv_kv, + vkr_percent, + vk_percent, + pfe_kw, + i0_percent, + shift_degree = data[key].xs("Theta θ (°)", level=1, axis=1).values[:, 0], + tap_pos = 0, + tap_neutral = 0, + tap_side = "lv", + tap_min = taps["tap_min"].values, + tap_max = taps["tap_max"].values, + tap_phase_shifter = phase_shifter, + tap_step_percent = du, + tap_step_degree = dphi, + name = data[key].loc[:, ("Location", "Full Name")].str.strip().values, + EIC_Code = data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], + TSO = data[key].xs("TSO", level=1, axis=1).values[:, 0], + Comment = data[key].xs("Comment", level=1, axis=1).replace("\xa0", "").values[:, 0], + ) + + +def _invent_connections_between_grid_groups( + net:pandapowerNet, minimal_trafo_invention:bool=False, **kwargs) -> None: + """Adds connections between islanded grid groups via: + + - adding transformers between equally named buses that have different voltage level and lay in different groups + - merge buses of same voltage level, different grid groups and equal name base + - fuse buses that are close to each other + + Parameters + ---------- + net : pandapowerNet + net to be manipulated + minimal_trafo_invention : bool, optional + if True, adding transformers stops when no grid groups is islanded anymore (does not apply + for release version 5 or 6, i.e. it does not care what value is passed to + minimal_trafo_invention). If False, all equally named buses that have different voltage + level and lay in different groups will be connected via additional transformers, + by default False + """ + grid_groups = get_grid_groups(net) + bus_idx = _get_bus_idx(net) + bus_grid_groups = pd.concat([pd.Series(group, index=buses) for group, buses in zip( + grid_groups.index, grid_groups.buses)]).sort_index() + + # treat for example "Wuergau" equally as "Wuergau (2)": + location_names = pd.Series(bus_idx.index.get_level_values(0)) + location_names = location_names.str.replace(r"(.) \([0-9]+\)", r"\1", regex=True) + bus_idx.index = pd.MultiIndex.from_arrays( + [location_names.values, bus_idx.index.get_level_values(1).to_numpy()], + names=bus_idx.index.names) + + # --- add Transformers between equally named buses that have different voltage level and lay in + # --- different groups + connected_vn_kvs_by_trafos = pd.DataFrame({ + "hv": net.bus.vn_kv.loc[net.trafo.hv_bus.values].values, + "lv": net.bus.vn_kv.loc[net.trafo.lv_bus.values].values, + "index": net.trafo.index}).set_index(["hv", "lv"]).sort_index() + dupl_location_names = location_names[location_names.duplicated()] + + for location_name in dupl_location_names: + if minimal_trafo_invention and not len(bus_grid_groups.unique()) > 1: + break # break with regard to minimal_trafo_invention + grid_groups_at_location = bus_grid_groups.loc[bus_idx.loc[location_name].values] + grid_groups_at_location = grid_groups_at_location.drop_duplicates() + if len(grid_groups_at_location) < 2: + continue + elif len(grid_groups_at_location) > 2: + raise NotImplementedError("Code is not provided to invent Transformer connections " + "between locations with more than two grid groups, i.e. " + "voltage levels.") + TSO = net.bus.zone.at[grid_groups_at_location.index[0]] + vn_kvs = net.bus.vn_kv.loc[grid_groups_at_location.index].sort_values(ascending=False) + try: + trafos_connecting_same_voltage_levels = \ + connected_vn_kvs_by_trafos.loc[tuple(vn_kvs)] + except KeyError: + logger.info(f"For location {location_name}, no transformer data can be reused since " + f"no transformer connects {vn_kvs.sort_values(ascending=False).iat[0]} kV " + f"and {vn_kvs.sort_values(ascending=False).iat[1]} kV.") + continue + trafos_of_same_TSO = trafos_connecting_same_voltage_levels.loc[(net.bus.zone.loc[ + net.trafo.hv_bus.loc[trafos_connecting_same_voltage_levels.values.flatten( + )].values] == TSO).values].values.flatten() + + # from which trafo parameters are copied: + tr_to_be_copied = trafos_of_same_TSO[0] if len(trafos_of_same_TSO) else \ + trafos_connecting_same_voltage_levels.values.flatten()[0] + + # copy transformer data + duplicated_row = net.trafo.loc[[tr_to_be_copied]].copy() + duplicated_row.index = [net.trafo.index.max() + 1] # adjust index + duplicated_row.hv_bus = vn_kvs.index[0] # adjust hv_bus, lv_bus + duplicated_row.lv_bus = vn_kvs.index[1] # adjust hv_bus, lv_bus + duplicated_row.name = "additional transformer to connect the grid" + net.trafo = pd.concat([net.trafo, duplicated_row]) + + bus_grid_groups.loc[bus_grid_groups == grid_groups_at_location.iat[1]] = \ + grid_groups_at_location.iat[0] + + # --- merge buses of same voltage level, different grid groups and equal name base + bus_name_splits = net.bus.name.str.split(r"[ -/]+", expand=True) + buses_with_single_base = net.bus.name.loc[(~bus_name_splits.isnull()).sum(axis=1) == 1] + for idx, name_base in buses_with_single_base.items(): + same_name_base = net.bus.drop(idx).name.str.contains(name_base) + if not any(same_name_base): + continue + other_group = bus_grid_groups.drop(idx) != bus_grid_groups.at[idx] + same_vn = net.bus.drop(idx).vn_kv == net.bus.vn_kv.at[idx] + is_fuse_candidate = same_name_base & other_group & same_vn + if not any(is_fuse_candidate): + continue + to_fuse = bus_grid_groups.drop(idx).loc[is_fuse_candidate].drop_duplicates() + fuse_buses(net, idx, set(to_fuse.index)) + + bus_grid_groups.loc[bus_grid_groups.isin(bus_grid_groups.drop(idx).loc[ + is_fuse_candidate].unique())] = grid_groups_at_location.iat[0] + bus_grid_groups = bus_grid_groups.drop(to_fuse.index) + + # --- fuse buses that are close to each other + for name1, name2 in [("CROISIERE", "BOLLENE (POSTE RESEAU)"), + ("CAEN", "DRONNIERE (LA)"), + ("TRINITE-VICTOR", "MENTON/TRINITE VICTOR")]: + b1 = net.bus.index[net.bus.name == name1] + b2 = net.bus.index[net.bus.name == name2] + if len(b1) == 1 and len(b2) >= 1: + fuse_buses(net, b1[0], set(b2)) + bus_grid_groups = bus_grid_groups.drop(b2) + else: + logger.info("Buses of the following names were intended to be fused but were not found." + f"\n'{name1}' and '{name2}'") + + +def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:int|str, **kwargs) -> None: + """Drops grid groups that are islanded and include a number of buses below min_bus_number. + + Parameters + ---------- + net : pandapowerNet + net in which islanded grid groups will be dropped + min_bus_number : int | str, optional + Threshold value to decide which small grid groups should be dropped and which large grid + groups should be kept. If all islanded grid groups should be dropped except of the one + largest, set "max". If all grid groups that do not contain a slack element should be + dropped, set "unsupplied". + """ + def _grid_groups_to_drop_by_min_bus_number(): + return grid_groups.loc[grid_groups["n_buses"] < min_bus_number] + + grid_groups = get_grid_groups(net, **kwargs) + + if min_bus_number == "unsupplied": + slack_buses = set(net.ext_grid.loc[net.ext_grid.in_service, "bus"]) | \ + set(net.gen.loc[net.gen.in_service & net.gen.slack, "bus"]) + grid_groups_to_drop = grid_groups.loc[~grid_groups.buses.apply( + lambda x: not x.isdisjoint(slack_buses))] + + elif min_bus_number == "max": + min_bus_number = grid_groups["n_buses"].max() + grid_groups_to_drop = _grid_groups_to_drop_by_min_bus_number() + + elif isinstance(min_bus_number, int): + grid_groups_to_drop = _grid_groups_to_drop_by_min_bus_number() + + else: + raise NotImplementedError( + f"{min_bus_number=} is not implemented. Use an int, 'max', or 'unsupplied' instead.") + + buses_to_drop = reduce(set.union, grid_groups_to_drop.buses) + drop_buses(net, buses_to_drop) + logger.info(f"drop_islanded_grid_groups() drops {len(grid_groups_to_drop)} grid groups with a " + f"total of {grid_groups_to_drop.n_buses.sum()} buses.") + + +def _add_bus_geo(net:pandapowerNet, line_geo_data:pd.DataFrame) -> None: + """Adds geodata to the buses. The function needs to handle cases where line_geo_data does not + include no or multiple geodata per bus. Primarly, the geodata are allocate via EIC Code names, + if ambigous, names are considered. + + Parameters + ---------- + net : pandapowerNet + net in which geodata are added to the buses + line_geo_data : pd.DataFrame + Converted geodata from the html file + """ + iSl = pd.IndexSlice + lgd_EIC_bus = line_geo_data.pivot_table(values="value", index=["EIC_Code", "bus"], + columns="geo_dim") + lgd_name_bus = line_geo_data.pivot_table(values="value", index=["name", "bus"], + columns="geo_dim") + lgd_EIC_bus_idx_extended = pd.MultiIndex.from_frame(lgd_EIC_bus.index.to_frame().assign( + **dict(col_name="EIC_Code")).rename(columns=dict(EIC_Code="identifier")).loc[ + :, ["col_name", "identifier", "bus"]]) + lgd_name_bus_idx_extended = pd.MultiIndex.from_frame(lgd_name_bus.index.to_frame().assign( + **dict(col_name="name")).rename(columns=dict(name="identifier")).loc[ + :, ["col_name", "identifier", "bus"]]) + lgd_bus = pd.concat([lgd_EIC_bus.set_axis(lgd_EIC_bus_idx_extended), + lgd_name_bus.set_axis(lgd_name_bus_idx_extended)]) + dupl_EICs = net.line.EIC_Code.loc[net.line.EIC_Code.duplicated()] + dupl_names = net.line.name.loc[net.line.name.duplicated()] + + def _geo_json_str(this_bus_geo:pd.Series) -> str: + return f'{{"coordinates": [{this_bus_geo.at["lng"]}, {this_bus_geo.at["lat"]}], "type": "Point"}}' + + def _add_bus_geo_inner(bus:int) -> str|None: + from_bus_line_excerpt = net.line.loc[net.line.from_bus == bus, ["EIC_Code", "name", "Tieline"]] + to_bus_line_excerpt = net.line.loc[net.line.to_bus == bus, ["EIC_Code", "name", "Tieline"]] + line_excerpt = pd.concat([from_bus_line_excerpt, to_bus_line_excerpt]) + n_connected_line_ends = len(line_excerpt) + if n_connected_line_ends == 0: + logger.error(f"Bus {bus} (name {net.bus.at[bus, 'name']}) is not found in line_geo_data.") + return None + is_dupl = pd.concat([ + pd.DataFrame({"EIC": from_bus_line_excerpt.EIC_Code.isin(dupl_EICs).values, + "name": from_bus_line_excerpt.name.isin(dupl_names).values}, + index=pd.MultiIndex.from_product([["from"], from_bus_line_excerpt.index], + names=["bus", "line_index"])), + pd.DataFrame({"EIC": to_bus_line_excerpt.EIC_Code.isin(dupl_EICs).values, + "name": to_bus_line_excerpt.name.isin(dupl_names).values}, + index=pd.MultiIndex.from_product([["to"], to_bus_line_excerpt.index], + names=["bus", "line_index"])) + ]) + is_missing = pd.DataFrame({ + "EIC": ~line_excerpt.EIC_Code.isin( + lgd_bus.loc["EIC_Code"].index.get_level_values("identifier")), + "name": ~line_excerpt.name.isin( + lgd_bus.loc["name"].index.get_level_values("identifier")) + }).set_axis(is_dupl.index) + is_tieline = pd.Series(net.line.loc[is_dupl.index.get_level_values("line_index"), + "Tieline"].values, index=is_dupl.index) + + # --- construct access_vals, i.e. values to take line geo data from lgd_bus + # --- if not duplicated, take "EIC_Code". Otherwise and if not dupl, take "name". + # --- Otherwise ignore. Do it for both from and to bus + access_vals = pd.DataFrame({ + "col_name": "EIC_Code", + "identifier": line_excerpt.EIC_Code.values, + "bus": is_dupl.index.get_level_values("bus").values + }) # default is EIC_Code + take_from_name = ((is_dupl.EIC | is_missing.EIC) & (~is_dupl.name & ~is_missing.name)).values + access_vals.loc[take_from_name, "col_name"] = "name" + access_vals.loc[take_from_name, "identifier"] = line_excerpt.name.loc[take_from_name].values + keep = (~(is_dupl | is_missing)).any(axis=1).values + if np.all(is_missing): + log_msg = (f"For bus {bus} (name {net.bus.at[bus, 'name']}), {n_connected_line_ends} " + "were found but no EIC_Codes or names of corresponding lines were found ." + "in the geo data from the html file.") + if is_tieline.all(): + logger.debug(log_msg) + else: + logger.warning(log_msg) + return None + elif sum(keep) == 0: + logger.info(f"For {bus=}, all EIC_Codes and names of connected lines are ambiguous. " + "No geo data is dropped at this point.") + keep[(~is_missing).any(axis=1)] = True + access_vals = access_vals.loc[keep] + + # --- get this_bus_geo from EIC_Code or name with regard to access_vals + this_bus_geo = lgd_bus.loc[iSl[ + access_vals.col_name, access_vals.identifier, access_vals.bus], :] + + if len(this_bus_geo) > 1: + # reduce similar/equal lines + this_bus_geo = this_bus_geo.loc[this_bus_geo.round(2).drop_duplicates().index] + + # --- return geo_json_str + len_this_bus_geo = len(this_bus_geo) + if len_this_bus_geo == 1: + return _geo_json_str(this_bus_geo.iloc[0]) + elif len_this_bus_geo == 2: + how_often = pd.Series( + [sum(np.isclose(lgd_EIC_bus["lat"], this_bus_geo["lat"].iat[i]) & \ + np.isclose(lgd_EIC_bus["lng"], this_bus_geo["lng"].iat[i])) for i in + range(len_this_bus_geo)], index=this_bus_geo.index) + if how_often.at[how_often.idxmax()] >= 1: + logger.warning(f"Bus {bus} (name {net.bus.at[bus, 'name']}) was found multiple times" + " in line_geo_data. No value exists more often than others. " + "The first of most used geo positions is used.") + return _geo_json_str(this_bus_geo.loc[how_often.idxmax()]) + + net.bus.geo = [_add_bus_geo_inner(bus) for bus in net.bus.index] + + +# --- tertiary functions --------------------------------------------------------------------------- + +def _float_col_comma_correction(data:dict[str, pd.DataFrame], key:str, col_names:list): + for col_name in col_names: + data[key][col_name] = pd.to_numeric(data[key][col_name].astype(str).str.replace( + ",", "."), errors="coerce") + + +def _get_transformer_voltages( + data:dict[str, pd.DataFrame], bus_idx:pd.Series) -> tuple[np.ndarray, np.ndarray]: + + key = "Transformers" + vn = data[key].loc[:, [("Voltage_level(kV)", "Primary"), + ("Voltage_level(kV)", "Secondary")]].values + vn_hv_kv = np.max(vn, axis=1) + vn_lv_kv = np.min(vn, axis=1) + if is_integer_dtype(list(bus_idx.index.dtypes)[1]): + vn_hv_kv = vn_hv_kv.astype(int) + vn_lv_kv = vn_lv_kv.astype(int) + + return vn_hv_kv, vn_lv_kv + + +def _allocate_trafos_to_buses_and_create_buses( + net:pandapowerNet, data:dict[str, pd.DataFrame], bus_idx:pd.Series, + vn_hv_kv:np.ndarray, vn_lv_kv:np.ndarray, + rel_deviation_threshold_for_trafo_bus_creation:float=0.2, + log_rel_vn_deviation:float=0.12, **kwargs) -> pd.DataFrame: + """Provides a DataFrame of data to allocate transformers to the buses according to their + location names. If locations of transformers do not exist due to the data of the lines and + tielines sheets, additional buses are created. If locations exist but have a far different + voltage level than the transformer, either a warning is logged or additional buses are created + according to rel_deviation_threshold_for_trafo_bus_creation and log_rel_vn_deviation. + + Parameters + ---------- + net : pandapowerNet + pandapower net + data : dict[str, pd.DataFrame] + _description_ + bus_idx : pd.Series + Series of indices and corresponding location names and voltage levels in the MultiIndex of + the Series + vn_hv_kv : np.ndarray + nominal voltages of the hv side of the transformers + vn_lv_kv : np.ndarray + Nominal voltages of the lv side of the transformers + rel_deviation_threshold_for_trafo_bus_creation : float, optional + If the voltage level of transformer locations is far different than the transformer data, + additional buses are created. rel_deviation_threshold_for_trafo_bus_creation defines the + tolerance in which no additional buses are created. By default 0.2 + log_rel_vn_deviation : float, optional + This parameter allows a range below rel_deviation_threshold_for_trafo_bus_creation in which + a warning is logged instead of a creating additional buses. By default 0.12 + + Returns + ------- + pd.DataFrame + information to which bus the trafos should be connected to. Columns are + ["name", "hv_bus", "lv_bus", "vn_hv_kv", "vn_lv_kv", ...] + """ + + if rel_deviation_threshold_for_trafo_bus_creation < log_rel_vn_deviation: + logger.warning( + f"Given parameters violates the ineqation " + f"{rel_deviation_threshold_for_trafo_bus_creation=} >= {log_rel_vn_deviation=}. " + f"Therefore, rel_deviation_threshold_for_trafo_bus_creation={log_rel_vn_deviation} " + "is assumed.") + rel_deviation_threshold_for_trafo_bus_creation = log_rel_vn_deviation + + key = "Transformers" + bus_location_names = set(net.bus.name) + trafo_bus_names = data[key].loc[:, ("Location", "Full Name")] + trafo_location_names = _find_trafo_locations(trafo_bus_names, bus_location_names) + + # --- construct DataFrame trafo_connections including all information on trafo allocation to + # --- buses + empties = -1*np.ones(len(vn_hv_kv), dtype=int) + trafo_connections = pd.DataFrame({ + "name": trafo_location_names, + "hv_bus": empties, + "lv_bus": empties, + "vn_hv_kv": vn_hv_kv, + "vn_lv_kv": vn_lv_kv, + "vn_hv_kv_next_bus": vn_hv_kv, + "vn_lv_kv_next_bus": vn_lv_kv, + "hv_rel_deviation": np.zeros(len(vn_hv_kv)), + "lv_rel_deviation": np.zeros(len(vn_hv_kv)), + }) + trafo_connections[["hv_bus", "lv_bus"]] = trafo_connections[["hv_bus", "lv_bus"]].astype(np.int64) + + for side in ["hv", "lv"]: + bus_col, trafo_vn_col, next_col, rel_dev_col, has_dev_col = \ + f"{side}_bus", f"vn_{side}_kv", f"vn_{side}_kv_next_bus", f"{side}_rel_deviation", \ + f"trafo_{side}_to_bus_deviation" + name_vn_series = pd.Series(tuple(zip(trafo_location_names, trafo_connections[trafo_vn_col]))) + isin = name_vn_series.isin(bus_idx.index) + trafo_connections[has_dev_col] = ~isin + trafo_connections.loc[isin, bus_col] = bus_idx.loc[name_vn_series.loc[isin]].values + + # --- code to find bus locations with vn deviation + next_vn = np.array([bus_idx.loc[tln.name].index.values[ + (pd.Series(bus_idx.loc[tln.name].index) - getattr(tln, trafo_vn_col)).abs().idxmin( + )] for tln in trafo_connections.loc[~isin, ["name", trafo_vn_col]].itertuples()]) + trafo_connections.loc[~isin, next_col] = next_vn + rel_dev = np.abs(next_vn - trafo_connections.loc[~isin, trafo_vn_col].values) / next_vn + trafo_connections.loc[~isin, rel_dev_col] = rel_dev + trafo_connections.loc[~isin, bus_col] = \ + bus_idx.loc[list(tuple(zip(trafo_connections.loc[~isin, "name"], + trafo_connections.loc[~isin, next_col])))].values + + # --- create buses to avoid too large vn deviations between nodes and transformers + need_bus_creation = trafo_connections[rel_dev_col] > \ + rel_deviation_threshold_for_trafo_bus_creation + new_bus_data = pd.DataFrame({ + "vn_kv": trafo_connections.loc[need_bus_creation, trafo_vn_col].values, + "name": trafo_connections.loc[need_bus_creation, "name"].values, + "TSO": data[key].loc[need_bus_creation, ("Location", "TSO")].values + }) + new_bus_data_dd = _drop_duplicates_and_join_TSO(new_bus_data) + new_bus_idx = create_buses(net, len(new_bus_data_dd), vn_kv=new_bus_data_dd.vn_kv, + name=new_bus_data_dd.name, zone=new_bus_data_dd.TSO) + trafo_connections.loc[need_bus_creation, bus_col] = net.bus.loc[new_bus_idx, [ + "name", "vn_kv"]].reset_index().set_index(["name", "vn_kv"]).loc[list(new_bus_data[[ + "name", "vn_kv"]].itertuples(index=False, name=None))].values + trafo_connections.loc[need_bus_creation, next_col] = \ + trafo_connections.loc[need_bus_creation, trafo_vn_col].values + trafo_connections.loc[need_bus_creation, rel_dev_col] = 0 + trafo_connections.loc[need_bus_creation, has_dev_col] = False + + # --- create buses for trafos that are connected to the same bus at both sides (possible if + # --- vn_hv_kv < vn_lv_kv *(1+rel_deviation_threshold_for_trafo_bus_creation) which usually + # --- occurs for PSTs only) + same_bus_connection = trafo_connections.hv_bus == trafo_connections.lv_bus + duplicated_buses = net.bus.loc[trafo_connections.loc[same_bus_connection, "lv_bus"]].copy() + duplicated_buses["name"] += " (2)" + duplicated_buses.index = list(range(net.bus.index.max()+1, + net.bus.index.max()+1+len(duplicated_buses))) + trafo_connections.loc[same_bus_connection, "lv_bus"] = duplicated_buses.index + net.bus = pd.concat([net.bus, duplicated_buses]) + if n_add_buses := len(duplicated_buses): + tr_names = data[key].loc[trafo_connections.index[same_bus_connection], + ("Location", "Full Name")] + are_PSTs = tr_names.str.contains("PST") + logger.info(f"{n_add_buses} additional buses were created to avoid that transformers are " + f"connected to the same bus at both side, hv and lv. Of the causing " + f"{len(tr_names)} transformers, {sum(are_PSTs)} contain 'PST' in their name. " + f"According to this converter, the power flows over all these transformers will" + f" end at the additional buses. Please consider to connect lines with the " + f"additional buses, so that the power flow is over the (PST) transformers into " + f"the lines.") + + # --- log according to log_rel_vn_deviation + for side in ["hv", "lv"]: + need_logging = trafo_connections.loc[trafo_connections[has_dev_col], + rel_dev_col] > log_rel_vn_deviation + if n_need_logging := sum(need_logging): + max_dev = trafo_connections[rel_dev_col].max() + idx_max_dev = trafo_connections[rel_dev_col].idxmax() + logger.warning( + f"For {n_need_logging} Transformers ({side} side), only locations were found (orig" + f"in are the line and tieline data) that have a higher relative deviation than " + f"{log_rel_vn_deviation}. The maximum relative deviation is {max_dev} which " + f"results from a Transformer rated voltage of " + f"{trafo_connections.at[idx_max_dev, trafo_vn_col]} and a bus " + f"rated voltage (taken from Lines/Tielines data sheet) of " + f"{trafo_connections.at[idx_max_dev, next_col]}. The best locations were " + f"nevertheless applied, due to {rel_deviation_threshold_for_trafo_bus_creation=}") + + assert (trafo_connections.hv_bus > -1).all() + assert (trafo_connections.lv_bus > -1).all() + assert (trafo_connections.hv_bus != trafo_connections.lv_bus).all() + + return trafo_connections + + +def _find_trafo_locations(trafo_bus_names, bus_location_names): + # --- split (original and lower case) strings at " " separators to remove impeding parts for + # identifying the location names + trafo_bus_names_expended = trafo_bus_names.str.split(r"[ ]+|-A[0-9]+|-TD[0-9]+|-PF[0-9]+", + expand=True).fillna("").replace(" ", "") + trafo_bus_names_expended_lower = trafo_bus_names.str.lower().str.split( + r"[ ]+|-A[0-9]+|-TD[0-9]+|-PF[0-9]+", expand=True).fillna("").replace(" ", "") + + # --- identify impeding parts + contains_number = trafo_bus_names_expended.map(lambda x: any(char.isdigit() for char in x)) + to_drop = (trafo_bus_names_expended_lower == "tr") | (trafo_bus_names_expended_lower == "pst") \ + | (trafo_bus_names_expended == "") | (trafo_bus_names_expended == "/") | ( + trafo_bus_names_expended == "LIPST") | (trafo_bus_names_expended == "EHPST") | ( + trafo_bus_names_expended == "TFO") | (trafo_bus_names_expended_lower == "trafo") | ( + trafo_bus_names_expended_lower == "kv") | contains_number + trafo_bus_names_expended[to_drop] = "" + + # --- reconstruct name strings for identification + trafo_bus_names_joined = trafo_bus_names_expended.where(~to_drop).fillna('').agg( + ' '.join, axis=1).str.strip() + trafo_bus_names_longest_part = trafo_bus_names_expended.apply( + lambda row: max(row, key=len), axis=1) + joined_in_buses = trafo_bus_names_joined.isin(bus_location_names) + longest_part_in_buses = trafo_bus_names_longest_part.isin(bus_location_names) + + # --- check whether all name strings point at location names of the buses + if False: # for easy testing + fail = ~(joined_in_buses | longest_part_in_buses) + a = pd.concat([trafo_bus_names_joined.loc[fail], trafo_bus_names_longest_part.loc[fail]], axis=1) + + if n_bus_names_not_found := len(joined_in_buses) - sum(joined_in_buses | longest_part_in_buses): + raise ValueError( + f"For {n_bus_names_not_found} Tranformers, no suitable bus location names were found, " + f"i.e. the algorithm did not find a (part) of Transformers-Location-Full Name that fits" + " to Substation_1 or Substation_2 data in Lines or Tielines sheet.") + + # --- set the trafo location names and trafo bus indices respectively + trafo_location_names = trafo_bus_names_longest_part + trafo_location_names.loc[joined_in_buses] = trafo_bus_names_joined + + return trafo_location_names + + +def _drop_duplicates_and_join_TSO(bus_df:pd.DataFrame) -> pd.DataFrame: + bus_df = bus_df.drop_duplicates(ignore_index=True) + # just keep one bus per name and vn_kv. If there are multiple buses of different TSOs, join the + # TSO strings: + bus_df = bus_df.groupby(["name", "vn_kv"], as_index=False).agg({"TSO": lambda x: '/'.join(x)}) + assert not bus_df.duplicated(["name", "vn_kv"]).any() + return bus_df + + +def _get_float_column(df, col_tuple, fill=0): + series = df.loc[:, col_tuple] + series.loc[series == "\xa0"] = fill + return series.astype(float).fillna(fill) + + +def _get_bus_idx(net:pandapowerNet) -> pd.Series: + return net.bus[["name", "vn_kv"]].rename_axis("index").reset_index().set_index([ + "name", "vn_kv"])["index"] + + +def get_grid_groups(net:pandapowerNet, **kwargs) -> pd.DataFrame: + notravbuses_dict = dict() if "notravbuses" not in kwargs.keys() else { + "notravbuses": kwargs.pop("notravbuses")} + grid_group_buses = [set_ for set_ in connected_components(create_nxgraph(net, **kwargs), + **notravbuses_dict)] + grid_groups = pd.DataFrame({"buses": grid_group_buses}) + grid_groups["n_buses"] = grid_groups["buses"].apply(len) + return grid_groups + + +def _lng_lat_to_df(dict_:dict, line_EIC:str, line_name:str) -> pd.DataFrame: + return pd.DataFrame([ + [line_EIC, line_name, "from", "lng", dict_["lng"][0]], + [line_EIC, line_name, "to", "lng", dict_["lng"][1]], + [line_EIC, line_name, "from", "lat", dict_["lat"][0]], + [line_EIC, line_name, "to", "lat", dict_["lat"][1]], + ], columns=["EIC_Code", "name", "bus", "geo_dim", "value"]) + + +def _fill_geo_at_one_sided_branches_without_geo_extent(net:pandapowerNet): + + def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, pd.Index|int]: + av = dict() # availablitiy of geodata + av["bus_with_geo"] = net.bus.index[~net.bus.geo.isnull()] + av["lines_fbw_tbwo"] = net.line.index[net.line.from_bus.isin(av["bus_with_geo"]) & + (~net.line.to_bus.isin(av["bus_with_geo"]))] + av["lines_fbwo_tbw"] = net.line.index[(~net.line.from_bus.isin(av["bus_with_geo"])) & + net.line.to_bus.isin(av["bus_with_geo"])] + av["trafos_hvbw_lvbwo"] = net.trafo.index[net.trafo.hv_bus.isin(av["bus_with_geo"]) & + (~net.trafo.lv_bus.isin(av["bus_with_geo"]))] + av["trafos_hvbwo_lvbw"] = net.trafo.index[(~net.trafo.hv_bus.isin(av["bus_with_geo"])) & + net.trafo.lv_bus.isin(av["bus_with_geo"])] + av["n_lines_one_side_geo"] = len(av["lines_fbw_tbwo"])+len(av["lines_fbwo_tbw"]) + return av + + geo_avail = _check_geo_availablitiy(net) + while geo_avail["n_lines_one_side_geo"]: + + # copy available geodata to the other end of branches where geodata are missing + for et, bus_w_geo, bus_wo_geo, idx_key in zip( + ["line", "line", "trafo", "trafo"], + ["to_bus", "from_bus", "lv_bus", "hv_bus"], + ["from_bus", "to_bus", "hv_bus", "lv_bus"], + ["lines_fbwo_tbw", "lines_fbw_tbwo", "trafos_hvbwo_lvbw", "trafos_hvbw_lvbwo"]): + net.bus.loc[net[et].loc[geo_avail[idx_key], bus_wo_geo].values, "geo"] = \ + net.bus.loc[net[et].loc[geo_avail[idx_key], bus_w_geo].values, "geo"].values + geo_avail = _check_geo_availablitiy(net) + + set_line_geodata_from_bus_geodata(net) + + +if __name__ == "__main__": + from pathlib import Path + import os + import pandapower as pp + + home = str(Path.home()) + jao_data_folder = os.path.join(home, "Documents", "JAO Static Grid Model") + + release5 = os.path.join(jao_data_folder, "20240329_Core Static Grid Model – 5th release") + excel_file_path = os.path.join(release5, "20240329_Core Static Grid Model_public.xlsx") + html_file_path = os.path.join(release5, "20240329_Core Static Grid Model Map_public", + "2024-03-18_Core_SGM_publication.html") + + release6 = os.path.join(jao_data_folder, "202409_Core Static Grid Mode_6th release") + excel_file_path = os.path.join(release6, "20240916_Core Static Grid Model_for publication.xlsx") + html_file_path = os.path.join(release6, "2024-09-13_Core_SGM_publication_files", + "2024-09-13_Core_SGM_publication.html") + + pp_net_json_file = os.path.join(home, "desktop", "jao_grid.json") + + if 1: # read from original data + net = from_jao(excel_file_path, html_file_path, True, drop_grid_groups_islands=True) + pp.to_json(net, pp_net_json_file) + else: # load net from already converted and stored net + net = pp.from_json(pp_net_json_file) + + print(net) + grid_groups = get_grid_groups(net) + print(grid_groups) + + _fill_geo_at_one_sided_branches_without_geo_extent(net) diff --git a/pandapower/test/converter/jao_testfiles/testfile.xlsx b/pandapower/test/converter/jao_testfiles/testfile.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..01dbecacbb9a1a133f4ed1f9d3b6318a92d9df8d GIT binary patch literal 76698 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 JX^@IA{vUzW1#bWV literal 0 HcmV?d00001 diff --git a/pandapower/test/converter/test_from_jao.py b/pandapower/test/converter/test_from_jao.py new file mode 100644 index 000000000..100f0fdc4 --- /dev/null +++ b/pandapower/test/converter/test_from_jao.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2024 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + +from copy import deepcopy +import os +import pytest +import numpy as np +import pandas as pd + +import pandapower as pp +from pandapower.converter import from_jao + + +def test_from_jao_with_testfile(): + testfile = os.path.join(pp.pp_dir, 'test', 'converter', "jao_testfiles", "testfile.xlsx") + assert os.path.isfile(testfile) + + # --- net1 + net1 = from_jao(testfile, None, False) + + assert len(net1.bus) == 10 + assert len(net1.line) == 7 + assert net1.line.Tieline.sum() == 2 + assert len(net1.trafo) == 1 + + # line data conversion + assert np.all((0.01 < net1.line[['r_ohm_per_km', 'x_ohm_per_km']]) & ( + net1.line[['r_ohm_per_km', 'x_ohm_per_km']] < 0.4)) + assert np.all((0.5 < net1.line['c_nf_per_km']) & (net1.line['c_nf_per_km'] < 25)) + assert np.all(net1.line['g_us_per_km'] < 1) + assert np.all((0.2 < net1.line['max_i_ka']) & (net1.line['max_i_ka'] < 5)) + + # trafo data conversion + assert 100 < net1.trafo.sn_mva.iat[0] < 1000 + assert 6 < net1.trafo.vk_percent.iat[0] < 65 + assert 0.25 < net1.trafo.vkr_percent.iat[0] < 1.2 + assert 10 < net1.trafo.pfe_kw.iat[0] < 1000 + assert net1.trafo.i0_percent.iat[0] < 0.1 + assert np.isclose(net1.trafo.shift_degree.iat[0], 90) + assert np.isclose(net1.trafo.tap_step_degree.iat[0], 1.794) + assert net1.trafo.tap_min.iat[0] == -17 + assert net1.trafo.tap_max.iat[0] == 17 + + # --- net2 + net2 = from_jao(testfile, None, True) + pp.nets_equal(net1, net2) # extend_data_for_grid_group_connections makes no difference here + + # --- net3 + net3 = from_jao(testfile, None, True, drop_grid_groups_islands=True) + assert len(net3.bus) == 6 + assert len(net3.line) == 5 + assert net3.line.Tieline.sum() == 1 + assert len(net3.trafo) == 1 + + +if __name__ == '__main__': + test_from_jao_with_testfile() + # pytest.main([__file__, "-xs"]) \ No newline at end of file From c5b132ef7cd3312d98f7ea4c9681539ffb1b4bf4 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Fri, 8 Nov 2024 16:08:50 +0100 Subject: [PATCH 02/15] solve reviewer comments --- pandapower/converter/jao/from_jao.py | 47 +++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 9ce6b05bf..1038ba803 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -30,6 +30,7 @@ def from_jao(excel_file_path:str, extend_data_for_grid_group_connections: bool, drop_grid_groups_islands: bool = False, apply_data_correction: bool = True, + max_i_ka_fillna:float|int=999, **kwargs) -> pandapowerNet: """Converts European (Core) EHV grid data provided by JAO, the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that operate in accordance to EU @@ -56,6 +57,9 @@ def from_jao(excel_file_path:str, min_bus_number (default is 6), by default False apply_data_correction : bool, optional _description_, by default True + max_i_ka_fillna : float | int, optional + value to fill missing values or data of false type in max_i_ka of lines and transformers. + If no value should be set, you can also pass np.nan. By default 999 Returns ------- @@ -96,7 +100,7 @@ def from_jao(excel_file_path:str, # --- manipulate data / data corrections if apply_data_correction: - html_str = _data_correction(data, html_str) + html_str = _data_correction(data, html_str, max_i_ka_fillna) # --- parse html_str to line_geo_data line_geo_data = None @@ -110,7 +114,7 @@ def from_jao(excel_file_path:str, net = create_empty_network(name=os.path.splitext(os.path.basename(excel_file_path))[0], **{key: val for key, val in kwargs.items() if key == "sn_mva"}) _create_buses_from_line_data(net, data) - _create_lines(net, data) + _create_lines(net, data, max_i_ka_fillna) _create_transformers_and_buses(net, data, **kwargs) # --- invent connections between grid groups @@ -131,7 +135,10 @@ def from_jao(excel_file_path:str, # --- secondary functions -------------------------------------------------------------------------- -def _data_correction(data:dict[str, pd.DataFrame], html_str:str|None) -> str|None: +def _data_correction( + data:dict[str, pd.DataFrame], + html_str:str|None, + max_i_ka_fillna:float|int) -> str|None: """Corrects input data in particular with regard to obvious weaknesses in the data provided, such as inconsistent spellings and missing necessary information @@ -141,12 +148,21 @@ def _data_correction(data:dict[str, pd.DataFrame], html_str:str|None) -> str|Non data provided by the excel file which will be corrected html_str : str | None data provided by the html file which will be corrected + max_i_ka_fillna : float | int + value to fill missing values or data of false type in max_i_ka of lines and transformers. + If no value should be set, you can also pass np.nan. Returns ------- str corrected html_str """ + # old name -> new name + rename_locnames = {"PSTMIKULOWA": "PST MIKULOWA", + "Chelm": "CHELM", + "OLSZTYN-MATK": "OLSZTYN-MATKI", + "STANISLAWOW": "Stanislawow", + "VIERRADEN": "Vierraden"} # --- Line and Tieline data --------------------------- for key in ["Lines", "Tielines"]: @@ -162,8 +178,9 @@ def _data_correction(data:dict[str, pd.DataFrame], html_str:str|None) -> str|Non # --- correct comma separation and cast to floats data[key][("Maximum Current Imax (A)", "Fixed")] = \ - data[key][("Maximum Current Imax (A)", "Fixed")].replace("\xa0", 999e3).replace( - "-", 999e3).replace(" ", 999e3) + data[key][("Maximum Current Imax (A)", "Fixed")].replace( + "\xa0", max_i_ka_fillna*1e3).replace( + "-", max_i_ka_fillna*1e3).replace(" ", max_i_ka_fillna*1e3) col_names = [("Electrical Parameters", col_level1) for col_level1 in [ "Length_(km)", "Resistance_R(Ω)", "Reactance_X(Ω)", "Susceptance_B(μS)", "Length_(km)"]] + [("Maximum Current Imax (A)", "Fixed")] @@ -173,19 +190,15 @@ def _data_correction(data:dict[str, pd.DataFrame], html_str:str|None) -> str|Non for loc_name in [(None, "NE_name"), ("Substation_1", "Full_name"), ("Substation_2", "Full_name")]: data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace( - "STANISLAWOW", "Stanislawow").str.replace("VIERRADEN", "Vierraden") - html_str = html_str.replace("STANISLAWOW", "Stanislawow").replace("Chelm", "CHELM") + rename_locnames) + html_str = html_str.replace(rename_locnames) # --- Transformer data -------------------------------- key = "Transformers" # --- fix Locations loc_name = ("Location", "Full Name") - data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace( - "PSTMIKULOWA", "PST MIKULOWA").str.replace( - "Chelm", "CHELM").str.replace( - "OLSZTYN-MATK", "OLSZTYN-MATKI").str.replace( - "STANISLAWOW", "Stanislawow").str.replace("VIERRADEN", "Vierraden") + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace(rename_locnames) # --- fix data in nonnull_taps taps = data[key].loc[:, ("Phase Shifting Properties", "Taps used for RAO")].fillna("").astype( @@ -287,7 +300,10 @@ def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame] assert np.allclose(new_bus_idx, bus_df.index) -def _create_lines(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: +def _create_lines( + net:pandapowerNet, + data:dict[str, pd.DataFrame], + max_i_ka_fillna:float|int) -> None: """Creates lines to the pandapower net using information from the lines and tielines sheets (excel file). @@ -297,6 +313,9 @@ def _create_lines(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: net to be filled by buses data : dict[str, pd.DataFrame] data provided by the excel file which will be corrected + max_i_ka_fillna : float | int + value to fill missing values or data of false type in max_i_ka of lines and transformers. + If no value should be set, you can also pass np.nan. """ bus_idx = _get_bus_idx(net) @@ -322,7 +341,7 @@ def _create_lines(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: data[key][("Electrical Parameters", "Resistance_R(Ω)")].values / length_km, data[key][("Electrical Parameters", "Reactance_X(Ω)")].values / length_km, data[key][("Electrical Parameters", "Susceptance_B(μS)")].values / length_km, - data[key][("Maximum Current Imax (A)", "Fixed")].fillna(999000).values / 1e3, + data[key][("Maximum Current Imax (A)", "Fixed")].fillna(max_i_ka_fillna*1e3).values / 1e3, name=data[key].xs("NE_name", level=1, axis=1).values[:, 0], EIC_Code=data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], TSO=data[key].xs("TSO", level=1, axis=1).values[:, 0], From 1aca8713e21838d078209720ef245ba36545efce Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Fri, 8 Nov 2024 17:13:18 +0100 Subject: [PATCH 03/15] add jao consideration in docs and improve docstring --- doc/converter.rst | 1 + doc/converter/jao.rst | 9 +++++++ pandapower/converter/jao/from_jao.py | 40 +++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 doc/converter/jao.rst diff --git a/doc/converter.rst b/doc/converter.rst index 7805ae123..633d7a32b 100644 --- a/doc/converter.rst +++ b/doc/converter.rst @@ -16,4 +16,5 @@ These tools are: converter/matpower converter/powerfactory converter/cgmes + converter/jao diff --git a/doc/converter/jao.rst b/doc/converter/jao.rst new file mode 100644 index 000000000..e91995163 --- /dev/null +++ b/doc/converter/jao.rst @@ -0,0 +1,9 @@ +Documentation for the JAO Static Grid Model Converter Function +============================================================== + +The ``from_jao`` function allows users to convert the Static Grid Model provided by JAO (Joint Allocation Office) into a pandapower network by reading and processing the provided Excel and HTML files. + +Function Overview +----------------- + +.. autofunction:: pandapower.converter.from_jao diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 1038ba803..1dd11fbcf 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -32,15 +32,30 @@ def from_jao(excel_file_path:str, apply_data_correction: bool = True, max_i_ka_fillna:float|int=999, **kwargs) -> pandapowerNet: - """Converts European (Core) EHV grid data provided by JAO, the "Single Allocation Platform (SAP) - for all European Transmission System Operators (TSOs) that operate in accordance to EU - legislation". At least in November 2024, the data are available at the website - https://www.jao.eu/static-grid-model . There, a map is provided to get an fine overview of the - geographical extent and the scope of the data. These inlcude information about European (Core) - lines, tielines, and transformers. No information is available on load or generation. + """Converts European (Core) EHV grid data provided by JAO (Joint Allocation Office), the + "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that + operate in accordance to EU legislation". + + **Data Sources and Availability:** + + The data are available at the website + `JAO Static Grid Model `_ (November 2024). + There, a map is provided to get an fine overview of the geographical extent and the scope of + the data. These inlcude information about European (Core) lines, tielines, and transformers. + + **Limitations:** + + No information is available on load or generation. The data quality with regard to the interconnection of the equipment, the information provided and the (incomplete) geodata should be considered with caution. + **Features of the converter:** + + - **Data Correction:** corrects known data inconsistencies, such as inconsistent spellings and missing necessary information. + - **Geographical Data Parsing:** Parses geographical data from the HTML file to add geolocation information to buses and lines. + - **Grid Group Connections:** Optionally extends the network by connecting islanded grid groups to avoid disconnected components. + - **Data Customization:** Allows for customization through additional parameters to control transformer creation, grid group dropping, and voltage level deviations. + Parameters ---------- excel_file_path : str @@ -64,7 +79,7 @@ def from_jao(excel_file_path:str, Returns ------- pandapowerNet - _description_ + net created from the jao data Additional Parameters --------------------- @@ -88,6 +103,17 @@ def from_jao(excel_file_path:str, This parameter allows a range below rel_deviation_threshold_for_trafo_bus_creation in which a warning is logged instead of a creating additional buses. By default 0.12 + Examples + -------- + >>> from pathlib import Path + >>> import os + >>> import pandapower as pp + >>> net = pp.converter.from_jao() + >>> home = str(Path.home()) + >>> # assume that the files are located at your desktop: + >>> excel_file_path = os.path.join(home, "desktop", "202409_Core Static Grid Mode_6th release") + >>> html_file_path = os.path.join(home, "desktop", "2024-09-13_Core_SGM_publication.html") + >>> net = from_jao(excel_file_path, html_file_path, True, drop_grid_groups_islands=True) """ # --- read data From f5481ca977dd9634caf2c84607c51aa53385f957 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 13 Nov 2024 08:26:05 +0100 Subject: [PATCH 04/15] fix typing of None in py3.9 --- pandapower/converter/jao/from_jao.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 1dd11fbcf..8b6e6769c 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -7,6 +7,7 @@ import os import json from functools import reduce +from typing import Optional import numpy as np import pandas as pd from pandas.api.types import is_integer_dtype, is_object_dtype @@ -26,7 +27,7 @@ def from_jao(excel_file_path:str, - html_file_path: str|None, + html_file_path: Optional[str], extend_data_for_grid_group_connections: bool, drop_grid_groups_islands: bool = False, apply_data_correction: bool = True, @@ -163,8 +164,8 @@ def from_jao(excel_file_path:str, def _data_correction( data:dict[str, pd.DataFrame], - html_str:str|None, - max_i_ka_fillna:float|int) -> str|None: + html_str:Optional[str], + max_i_ka_fillna:float|int) -> Optional[str]: """Corrects input data in particular with regard to obvious weaknesses in the data provided, such as inconsistent spellings and missing necessary information @@ -629,7 +630,7 @@ def _add_bus_geo(net:pandapowerNet, line_geo_data:pd.DataFrame) -> None: def _geo_json_str(this_bus_geo:pd.Series) -> str: return f'{{"coordinates": [{this_bus_geo.at["lng"]}, {this_bus_geo.at["lat"]}], "type": "Point"}}' - def _add_bus_geo_inner(bus:int) -> str|None: + def _add_bus_geo_inner(bus:int) -> Optional[str]: from_bus_line_excerpt = net.line.loc[net.line.from_bus == bus, ["EIC_Code", "name", "Tieline"]] to_bus_line_excerpt = net.line.loc[net.line.to_bus == bus, ["EIC_Code", "name", "Tieline"]] line_excerpt = pd.concat([from_bus_line_excerpt, to_bus_line_excerpt]) From 7fe23090b1a086033831c6d888e02cf46a6cb3db Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 13 Nov 2024 14:22:00 +0100 Subject: [PATCH 05/15] jao: further fixing python typing --- pandapower/converter/jao/from_jao.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 8b6e6769c..92dfe322f 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*-nt # Copyright (c) 2016-2024 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. @@ -7,7 +7,7 @@ import os import json from functools import reduce -from typing import Optional +from typing import Optional, Any import numpy as np import pandas as pd from pandas.api.types import is_integer_dtype, is_object_dtype @@ -31,7 +31,7 @@ def from_jao(excel_file_path:str, extend_data_for_grid_group_connections: bool, drop_grid_groups_islands: bool = False, apply_data_correction: bool = True, - max_i_ka_fillna:float|int=999, + max_i_ka_fillna:Any[float,int]=999, **kwargs) -> pandapowerNet: """Converts European (Core) EHV grid data provided by JAO (Joint Allocation Office), the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that @@ -91,7 +91,7 @@ def from_jao(excel_file_path:str, minimal_trafo_invention). If False, all equally named buses that have different voltage level and lay in different groups will be connected via additional transformers, by default False - min_bus_number : int|str, optional + min_bus_number : Any[int,str], optional Threshold value to decide which small grid groups should be dropped and which large grid groups should be kept. If all islanded grid groups should be dropped except of the one largest, set "max". If all grid groups that do not contain a slack element should be @@ -165,7 +165,7 @@ def from_jao(excel_file_path:str, def _data_correction( data:dict[str, pd.DataFrame], html_str:Optional[str], - max_i_ka_fillna:float|int) -> Optional[str]: + max_i_ka_fillna:Any[float,int]) -> Optional[str]: """Corrects input data in particular with regard to obvious weaknesses in the data provided, such as inconsistent spellings and missing necessary information @@ -330,7 +330,7 @@ def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame] def _create_lines( net:pandapowerNet, data:dict[str, pd.DataFrame], - max_i_ka_fillna:float|int) -> None: + max_i_ka_fillna:Any[float,int]) -> None: """Creates lines to the pandapower net using information from the lines and tielines sheets (excel file). @@ -558,7 +558,7 @@ def _invent_connections_between_grid_groups( f"\n'{name1}' and '{name2}'") -def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:int|str, **kwargs) -> None: +def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:Any[int,str], **kwargs) -> None: """Drops grid groups that are islanded and include a number of buses below min_bus_number. Parameters @@ -970,7 +970,7 @@ def _lng_lat_to_df(dict_:dict, line_EIC:str, line_name:str) -> pd.DataFrame: def _fill_geo_at_one_sided_branches_without_geo_extent(net:pandapowerNet): - def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, pd.Index|int]: + def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Any[pd.Index,int]]: av = dict() # availablitiy of geodata av["bus_with_geo"] = net.bus.index[~net.bus.geo.isnull()] av["lines_fbw_tbwo"] = net.line.index[net.line.from_bus.isin(av["bus_with_geo"]) & From 48739fa3e9563943a37929713d80813f286a1d49 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 13 Nov 2024 14:28:33 +0100 Subject: [PATCH 06/15] replace typing.Any by typing.Union (wrong usage) --- pandapower/converter/jao/from_jao.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 92dfe322f..d4bedf908 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -7,7 +7,7 @@ import os import json from functools import reduce -from typing import Optional, Any +from typing import Optional, Union import numpy as np import pandas as pd from pandas.api.types import is_integer_dtype, is_object_dtype @@ -31,7 +31,7 @@ def from_jao(excel_file_path:str, extend_data_for_grid_group_connections: bool, drop_grid_groups_islands: bool = False, apply_data_correction: bool = True, - max_i_ka_fillna:Any[float,int]=999, + max_i_ka_fillna:Union[float,int]=999, **kwargs) -> pandapowerNet: """Converts European (Core) EHV grid data provided by JAO (Joint Allocation Office), the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that @@ -91,7 +91,7 @@ def from_jao(excel_file_path:str, minimal_trafo_invention). If False, all equally named buses that have different voltage level and lay in different groups will be connected via additional transformers, by default False - min_bus_number : Any[int,str], optional + min_bus_number : Union[int,str], optional Threshold value to decide which small grid groups should be dropped and which large grid groups should be kept. If all islanded grid groups should be dropped except of the one largest, set "max". If all grid groups that do not contain a slack element should be @@ -165,7 +165,7 @@ def from_jao(excel_file_path:str, def _data_correction( data:dict[str, pd.DataFrame], html_str:Optional[str], - max_i_ka_fillna:Any[float,int]) -> Optional[str]: + max_i_ka_fillna:Union[float,int]) -> Optional[str]: """Corrects input data in particular with regard to obvious weaknesses in the data provided, such as inconsistent spellings and missing necessary information @@ -330,7 +330,7 @@ def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame] def _create_lines( net:pandapowerNet, data:dict[str, pd.DataFrame], - max_i_ka_fillna:Any[float,int]) -> None: + max_i_ka_fillna:Union[float,int]) -> None: """Creates lines to the pandapower net using information from the lines and tielines sheets (excel file). @@ -558,7 +558,7 @@ def _invent_connections_between_grid_groups( f"\n'{name1}' and '{name2}'") -def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:Any[int,str], **kwargs) -> None: +def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:Union[int,str], **kwargs) -> None: """Drops grid groups that are islanded and include a number of buses below min_bus_number. Parameters @@ -970,7 +970,7 @@ def _lng_lat_to_df(dict_:dict, line_EIC:str, line_name:str) -> pd.DataFrame: def _fill_geo_at_one_sided_branches_without_geo_extent(net:pandapowerNet): - def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Any[pd.Index,int]]: + def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Union[pd.Index,int]]: av = dict() # availablitiy of geodata av["bus_with_geo"] = net.bus.index[~net.bus.geo.isnull()] av["lines_fbw_tbwo"] = net.line.index[net.line.from_bus.isin(av["bus_with_geo"]) & From e4775729a506bec5e610577b8292bce266bbe138 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 13 Nov 2024 15:46:34 +0100 Subject: [PATCH 07/15] trial to fix the tests --- pandapower/converter/jao/from_jao.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index d4bedf908..7c7a1277c 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -185,11 +185,11 @@ def _data_correction( corrected html_str """ # old name -> new name - rename_locnames = {"PSTMIKULOWA": "PST MIKULOWA", - "Chelm": "CHELM", - "OLSZTYN-MATK": "OLSZTYN-MATKI", - "STANISLAWOW": "Stanislawow", - "VIERRADEN": "Vierraden"} + rename_locnames = [("PSTMIKULOWA", "PST MIKULOWA"), + ("Chelm", "CHELM"), + ("OLSZTYN-MATK", "OLSZTYN-MATKI"), + ("STANISLAWOW", "Stanislawow"), + ("VIERRADEN", "Vierraden")] # --- Line and Tieline data --------------------------- for key in ["Lines", "Tielines"]: @@ -216,8 +216,8 @@ def _data_correction( # --- consolidate to one way of name capitalization for loc_name in [(None, "NE_name"), ("Substation_1", "Full_name"), ("Substation_2", "Full_name")]: - data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace( - rename_locnames) + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().apply( + _multi_str_repl, repl=rename_locnames) html_str = html_str.replace(rename_locnames) # --- Transformer data -------------------------------- @@ -225,7 +225,8 @@ def _data_correction( # --- fix Locations loc_name = ("Location", "Full Name") - data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.replace(rename_locnames) + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.apply( + _multi_str_repl, repl=rename_locnames) # --- fix data in nonnull_taps taps = data[key].loc[:, ("Phase Shifting Properties", "Taps used for RAO")].fillna("").astype( @@ -1000,6 +1001,11 @@ def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Union[pd.Index,int]] set_line_geodata_from_bus_geodata(net) +def _multi_str_repl(st:str, repl:list[tuple]) -> str: + for (old, new) in repl: + return st.replace(old, new) + + if __name__ == "__main__": from pathlib import Path import os From 10e763ae4f52706c32a7c3f33b63e96a4e22860e Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 13 Nov 2024 16:51:35 +0100 Subject: [PATCH 08/15] fix test --- pandapower/converter/jao/from_jao.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 7c7a1277c..349951347 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -218,14 +218,14 @@ def _data_correction( ("Substation_2", "Full_name")]: data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().apply( _multi_str_repl, repl=rename_locnames) - html_str = html_str.replace(rename_locnames) + html_str = _multi_str_repl(html_str, rename_locnames) # --- Transformer data -------------------------------- key = "Transformers" # --- fix Locations loc_name = ("Location", "Full Name") - data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().str.apply( + data[key].loc[:, loc_name] = data[key].loc[:, loc_name].str.strip().apply( _multi_str_repl, repl=rename_locnames) # --- fix data in nonnull_taps From 7c6c7f6cfe7654de6dbc0f20b3904acec684153c Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Fri, 15 Nov 2024 15:03:43 +0100 Subject: [PATCH 09/15] just style refactoring --- pandapower/converter/jao/from_jao.py | 183 ++++++++++++++------------- 1 file changed, 97 insertions(+), 86 deletions(-) diff --git a/pandapower/converter/jao/from_jao.py b/pandapower/converter/jao/from_jao.py index 349951347..e527eb745 100644 --- a/pandapower/converter/jao/from_jao.py +++ b/pandapower/converter/jao/from_jao.py @@ -26,12 +26,12 @@ logger = logging.getLogger(__name__) -def from_jao(excel_file_path:str, +def from_jao(excel_file_path: str, html_file_path: Optional[str], extend_data_for_grid_group_connections: bool, drop_grid_groups_islands: bool = False, apply_data_correction: bool = True, - max_i_ka_fillna:Union[float,int]=999, + max_i_ka_fillna: Union[float, int] = 999, **kwargs) -> pandapowerNet: """Converts European (Core) EHV grid data provided by JAO (Joint Allocation Office), the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that @@ -163,9 +163,9 @@ def from_jao(excel_file_path:str, def _data_correction( - data:dict[str, pd.DataFrame], - html_str:Optional[str], - max_i_ka_fillna:Union[float,int]) -> Optional[str]: + data: dict[str, pd.DataFrame], + html_str: Optional[str], + max_i_ka_fillna: Union[float, int]) -> Optional[str]: """Corrects input data in particular with regard to obvious weaknesses in the data provided, such as inconsistent spellings and missing necessary information @@ -247,14 +247,15 @@ def _data_correction( if is_object_dtype(data[key].loc[:, ("Phase Shifting Properties", col)]): tr_double = data[key].index[data[key].loc[:, ( "Phase Shifting Properties", col)].str.contains("/").fillna(0).astype(bool)] - data[key].loc[tr_double, ("Phase Shifting Properties", col)] = data[key].loc[tr_double, - ("Phase Shifting Properties", col)].str.split("/", expand=True)[1].str.replace( - ",", ".").astype(float).values # take second info and correct separation: , -> . + data[key].loc[tr_double, ("Phase Shifting Properties", col)] = data[key].loc[ + tr_double, ("Phase Shifting Properties", col)].str.split("/", expand=True)[ + 1].str.replace(",", ".").astype(float).values # take second info and correct + # separation: , -> . return html_str -def _parse_html_str(html_str:str) -> pd.DataFrame: +def _parse_html_str(html_str: str) -> pd.DataFrame: """Converts ths geodata from the html file (information hidden in the string), from Lines in particular, to a DataFrame that can be used later in _add_bus_geo() @@ -268,7 +269,7 @@ def _parse_html_str(html_str:str) -> pd.DataFrame: pd.DataFrame extracted geodata for a later and easy use """ - def _filter_name(st:str) -> str: + def _filter_name(st: str) -> str: name_start = "NE name: " name_end = "" pos0 = st.find(name_start) + len(name_start) @@ -292,7 +293,7 @@ def _filter_name(st:str) -> str: len(polylines[6]))] line_name = [_filter_name(polylines[6][i]) for i in range(len(polylines[6]))] line_geo_data = pd.concat([_lng_lat_to_df(polylines[0][i][0][0], line_EIC[i], line_name[i]) for - i in range(len(polylines[0]))], ignore_index=True) + i in range(len(polylines[0]))], ignore_index=True) # remove trailing whitespaces for col in ["EIC_Code", "name"]: @@ -301,7 +302,7 @@ def _filter_name(st:str) -> str: return line_geo_data -def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame]) -> None: +def _create_buses_from_line_data(net: pandapowerNet, data: dict[str, pd.DataFrame]) -> None: """Creates buses to the pandapower net using information from the lines and tielines sheets (excel file). @@ -329,9 +330,9 @@ def _create_buses_from_line_data(net:pandapowerNet, data:dict[str, pd.DataFrame] def _create_lines( - net:pandapowerNet, - data:dict[str, pd.DataFrame], - max_i_ka_fillna:Union[float,int]) -> None: + net: pandapowerNet, + data: dict[str, pd.DataFrame], + max_i_ka_fillna: Union[float, int]) -> None: """Creates lines to the pandapower net using information from the lines and tielines sheets (excel file). @@ -369,17 +370,18 @@ def _create_lines( data[key][("Electrical Parameters", "Resistance_R(Ω)")].values / length_km, data[key][("Electrical Parameters", "Reactance_X(Ω)")].values / length_km, data[key][("Electrical Parameters", "Susceptance_B(μS)")].values / length_km, - data[key][("Maximum Current Imax (A)", "Fixed")].fillna(max_i_ka_fillna*1e3).values / 1e3, + data[key][("Maximum Current Imax (A)", "Fixed")].fillna( + max_i_ka_fillna*1e3).values / 1e3, name=data[key].xs("NE_name", level=1, axis=1).values[:, 0], EIC_Code=data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], TSO=data[key].xs("TSO", level=1, axis=1).values[:, 0], Comment=data[key].xs("Comment", level=1, axis=1).values[:, 0], - Tieline=key=="Tielines", - ) + Tieline=key == "Tielines", + ) def _create_transformers_and_buses( - net:pandapowerNet, data:dict[str, pd.DataFrame], **kwargs) -> None: + net: pandapowerNet, data: dict[str, pd.DataFrame], **kwargs) -> None: """Creates transformers to the pandapower net using information from the transformers sheet (excel file). @@ -417,7 +419,7 @@ def _create_transformers_and_buses( du = _get_float_column(data[key], ("Phase Shifting Properties", "Phase Regulation δu (%)")) dphi = _get_float_column(data[key], ("Phase Shifting Properties", "Angle Regulation δu (%)")) - phase_shifter = np.isclose(du, 0) & (~np.isclose(dphi, 0)) # Symmetrical/Asymmetrical not + phase_shifter = np.isclose(du, 0) & (~np.isclose(dphi, 0)) # Symmetrical/Asymmetrical not # considered _ = create_transformers_from_parameters( @@ -431,24 +433,24 @@ def _create_transformers_and_buses( vk_percent, pfe_kw, i0_percent, - shift_degree = data[key].xs("Theta θ (°)", level=1, axis=1).values[:, 0], - tap_pos = 0, - tap_neutral = 0, - tap_side = "lv", - tap_min = taps["tap_min"].values, - tap_max = taps["tap_max"].values, - tap_phase_shifter = phase_shifter, - tap_step_percent = du, - tap_step_degree = dphi, - name = data[key].loc[:, ("Location", "Full Name")].str.strip().values, - EIC_Code = data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], - TSO = data[key].xs("TSO", level=1, axis=1).values[:, 0], - Comment = data[key].xs("Comment", level=1, axis=1).replace("\xa0", "").values[:, 0], - ) + shift_degree=data[key].xs("Theta θ (°)", level=1, axis=1).values[:, 0], + tap_pos=0, + tap_neutral=0, + tap_side="lv", + tap_min=taps["tap_min"].values, + tap_max=taps["tap_max"].values, + tap_phase_shifter=phase_shifter, + tap_step_percent=du, + tap_step_degree=dphi, + name=data[key].loc[:, ("Location", "Full Name")].str.strip().values, + EIC_Code=data[key].xs("EIC_Code", level=1, axis=1).values[:, 0], + TSO=data[key].xs("TSO", level=1, axis=1).values[:, 0], + Comment=data[key].xs("Comment", level=1, axis=1).replace("\xa0", "").values[:, 0], + ) def _invent_connections_between_grid_groups( - net:pandapowerNet, minimal_trafo_invention:bool=False, **kwargs) -> None: + net: pandapowerNet, minimal_trafo_invention: bool = False, **kwargs) -> None: """Adds connections between islanded grid groups via: - adding transformers between equally named buses that have different voltage level and lay in different groups @@ -487,7 +489,7 @@ def _invent_connections_between_grid_groups( dupl_location_names = location_names[location_names.duplicated()] for location_name in dupl_location_names: - if minimal_trafo_invention and not len(bus_grid_groups.unique()) > 1: + if minimal_trafo_invention and len(bus_grid_groups.unique()) <= 1: break # break with regard to minimal_trafo_invention grid_groups_at_location = bus_grid_groups.loc[bus_idx.loc[location_name].values] grid_groups_at_location = grid_groups_at_location.drop_duplicates() @@ -559,7 +561,10 @@ def _invent_connections_between_grid_groups( f"\n'{name1}' and '{name2}'") -def drop_islanded_grid_groups(net:pandapowerNet, min_bus_number:Union[int,str], **kwargs) -> None: +def drop_islanded_grid_groups( + net: pandapowerNet, + min_bus_number: Union[int, str], + **kwargs) -> None: """Drops grid groups that are islanded and include a number of buses below min_bus_number. Parameters @@ -600,7 +605,7 @@ def _grid_groups_to_drop_by_min_bus_number(): f"total of {grid_groups_to_drop.n_buses.sum()} buses.") -def _add_bus_geo(net:pandapowerNet, line_geo_data:pd.DataFrame) -> None: +def _add_bus_geo(net: pandapowerNet, line_geo_data: pd.DataFrame) -> None: """Adds geodata to the buses. The function needs to handle cases where line_geo_data does not include no or multiple geodata per bus. Primarly, the geodata are allocate via EIC Code names, if ambigous, names are considered. @@ -613,9 +618,9 @@ def _add_bus_geo(net:pandapowerNet, line_geo_data:pd.DataFrame) -> None: Converted geodata from the html file """ iSl = pd.IndexSlice - lgd_EIC_bus = line_geo_data.pivot_table(values="value", index=["EIC_Code", "bus"], - columns="geo_dim") - lgd_name_bus = line_geo_data.pivot_table(values="value", index=["name", "bus"], + lgd_EIC_bus = line_geo_data.pivot_table(values="value", index=["EIC_Code", "bus"], + columns="geo_dim") + lgd_name_bus = line_geo_data.pivot_table(values="value", index=["name", "bus"], columns="geo_dim") lgd_EIC_bus_idx_extended = pd.MultiIndex.from_frame(lgd_EIC_bus.index.to_frame().assign( **dict(col_name="EIC_Code")).rename(columns=dict(EIC_Code="identifier")).loc[ @@ -628,26 +633,28 @@ def _add_bus_geo(net:pandapowerNet, line_geo_data:pd.DataFrame) -> None: dupl_EICs = net.line.EIC_Code.loc[net.line.EIC_Code.duplicated()] dupl_names = net.line.name.loc[net.line.name.duplicated()] - def _geo_json_str(this_bus_geo:pd.Series) -> str: + def _geo_json_str(this_bus_geo: pd.Series) -> str: return f'{{"coordinates": [{this_bus_geo.at["lng"]}, {this_bus_geo.at["lat"]}], "type": "Point"}}' - def _add_bus_geo_inner(bus:int) -> Optional[str]: - from_bus_line_excerpt = net.line.loc[net.line.from_bus == bus, ["EIC_Code", "name", "Tieline"]] + def _add_bus_geo_inner(bus: int) -> Optional[str]: + from_bus_line_excerpt = net.line.loc[net.line.from_bus == + bus, ["EIC_Code", "name", "Tieline"]] to_bus_line_excerpt = net.line.loc[net.line.to_bus == bus, ["EIC_Code", "name", "Tieline"]] line_excerpt = pd.concat([from_bus_line_excerpt, to_bus_line_excerpt]) n_connected_line_ends = len(line_excerpt) if n_connected_line_ends == 0: - logger.error(f"Bus {bus} (name {net.bus.at[bus, 'name']}) is not found in line_geo_data.") + logger.error( + f"Bus {bus} (name {net.bus.at[bus, 'name']}) is not found in line_geo_data.") return None is_dupl = pd.concat([ pd.DataFrame({"EIC": from_bus_line_excerpt.EIC_Code.isin(dupl_EICs).values, "name": from_bus_line_excerpt.name.isin(dupl_names).values}, - index=pd.MultiIndex.from_product([["from"], from_bus_line_excerpt.index], - names=["bus", "line_index"])), + index=pd.MultiIndex.from_product([["from"], from_bus_line_excerpt.index], + names=["bus", "line_index"])), pd.DataFrame({"EIC": to_bus_line_excerpt.EIC_Code.isin(dupl_EICs).values, "name": to_bus_line_excerpt.name.isin(dupl_names).values}, - index=pd.MultiIndex.from_product([["to"], to_bus_line_excerpt.index], - names=["bus", "line_index"])) + index=pd.MultiIndex.from_product([["to"], to_bus_line_excerpt.index], + names=["bus", "line_index"])) ]) is_missing = pd.DataFrame({ "EIC": ~line_excerpt.EIC_Code.isin( @@ -665,8 +672,9 @@ def _add_bus_geo_inner(bus:int) -> Optional[str]: "col_name": "EIC_Code", "identifier": line_excerpt.EIC_Code.values, "bus": is_dupl.index.get_level_values("bus").values - }) # default is EIC_Code - take_from_name = ((is_dupl.EIC | is_missing.EIC) & (~is_dupl.name & ~is_missing.name)).values + }) # default is EIC_Code + take_from_name = ((is_dupl.EIC | is_missing.EIC) & ( + ~is_dupl.name & ~is_missing.name)).values access_vals.loc[take_from_name, "col_name"] = "name" access_vals.loc[take_from_name, "identifier"] = line_excerpt.name.loc[take_from_name].values keep = (~(is_dupl | is_missing)).any(axis=1).values @@ -681,7 +689,7 @@ def _add_bus_geo_inner(bus:int) -> Optional[str]: return None elif sum(keep) == 0: logger.info(f"For {bus=}, all EIC_Codes and names of connected lines are ambiguous. " - "No geo data is dropped at this point.") + "No geo data is dropped at this point.") keep[(~is_missing).any(axis=1)] = True access_vals = access_vals.loc[keep] @@ -699,13 +707,13 @@ def _add_bus_geo_inner(bus:int) -> Optional[str]: return _geo_json_str(this_bus_geo.iloc[0]) elif len_this_bus_geo == 2: how_often = pd.Series( - [sum(np.isclose(lgd_EIC_bus["lat"], this_bus_geo["lat"].iat[i]) & \ + [sum(np.isclose(lgd_EIC_bus["lat"], this_bus_geo["lat"].iat[i]) & np.isclose(lgd_EIC_bus["lng"], this_bus_geo["lng"].iat[i])) for i in range(len_this_bus_geo)], index=this_bus_geo.index) if how_often.at[how_often.idxmax()] >= 1: logger.warning(f"Bus {bus} (name {net.bus.at[bus, 'name']}) was found multiple times" - " in line_geo_data. No value exists more often than others. " - "The first of most used geo positions is used.") + " in line_geo_data. No value exists more often than others. " + "The first of most used geo positions is used.") return _geo_json_str(this_bus_geo.loc[how_often.idxmax()]) net.bus.geo = [_add_bus_geo_inner(bus) for bus in net.bus.index] @@ -713,14 +721,14 @@ def _add_bus_geo_inner(bus:int) -> Optional[str]: # --- tertiary functions --------------------------------------------------------------------------- -def _float_col_comma_correction(data:dict[str, pd.DataFrame], key:str, col_names:list): +def _float_col_comma_correction(data: dict[str, pd.DataFrame], key: str, col_names: list): for col_name in col_names: data[key][col_name] = pd.to_numeric(data[key][col_name].astype(str).str.replace( ",", "."), errors="coerce") def _get_transformer_voltages( - data:dict[str, pd.DataFrame], bus_idx:pd.Series) -> tuple[np.ndarray, np.ndarray]: + data: dict[str, pd.DataFrame], bus_idx: pd.Series) -> tuple[np.ndarray, np.ndarray]: key = "Transformers" vn = data[key].loc[:, [("Voltage_level(kV)", "Primary"), @@ -735,10 +743,10 @@ def _get_transformer_voltages( def _allocate_trafos_to_buses_and_create_buses( - net:pandapowerNet, data:dict[str, pd.DataFrame], bus_idx:pd.Series, - vn_hv_kv:np.ndarray, vn_lv_kv:np.ndarray, - rel_deviation_threshold_for_trafo_bus_creation:float=0.2, - log_rel_vn_deviation:float=0.12, **kwargs) -> pd.DataFrame: + net: pandapowerNet, data: dict[str, pd.DataFrame], bus_idx: pd.Series, + vn_hv_kv: np.ndarray, vn_lv_kv: np.ndarray, + rel_deviation_threshold_for_trafo_bus_creation: float = 0.2, + log_rel_vn_deviation: float = 0.12, **kwargs) -> pd.DataFrame: """Provides a DataFrame of data to allocate transformers to the buses according to their location names. If locations of transformers do not exist due to the data of the lines and tielines sheets, additional buses are created. If locations exist but have a far different @@ -790,23 +798,25 @@ def _allocate_trafos_to_buses_and_create_buses( # --- buses empties = -1*np.ones(len(vn_hv_kv), dtype=int) trafo_connections = pd.DataFrame({ - "name": trafo_location_names, - "hv_bus": empties, - "lv_bus": empties, - "vn_hv_kv": vn_hv_kv, - "vn_lv_kv": vn_lv_kv, - "vn_hv_kv_next_bus": vn_hv_kv, - "vn_lv_kv_next_bus": vn_lv_kv, - "hv_rel_deviation": np.zeros(len(vn_hv_kv)), - "lv_rel_deviation": np.zeros(len(vn_hv_kv)), - }) - trafo_connections[["hv_bus", "lv_bus"]] = trafo_connections[["hv_bus", "lv_bus"]].astype(np.int64) + "name": trafo_location_names, + "hv_bus": empties, + "lv_bus": empties, + "vn_hv_kv": vn_hv_kv, + "vn_lv_kv": vn_lv_kv, + "vn_hv_kv_next_bus": vn_hv_kv, + "vn_lv_kv_next_bus": vn_lv_kv, + "hv_rel_deviation": np.zeros(len(vn_hv_kv)), + "lv_rel_deviation": np.zeros(len(vn_hv_kv)), + }) + trafo_connections[["hv_bus", "lv_bus"]] = trafo_connections[[ + "hv_bus", "lv_bus"]].astype(np.int64) for side in ["hv", "lv"]: bus_col, trafo_vn_col, next_col, rel_dev_col, has_dev_col = \ f"{side}_bus", f"vn_{side}_kv", f"vn_{side}_kv_next_bus", f"{side}_rel_deviation", \ f"trafo_{side}_to_bus_deviation" - name_vn_series = pd.Series(tuple(zip(trafo_location_names, trafo_connections[trafo_vn_col]))) + name_vn_series = pd.Series( + tuple(zip(trafo_location_names, trafo_connections[trafo_vn_col]))) isin = name_vn_series.isin(bus_idx.index) trafo_connections[has_dev_col] = ~isin trafo_connections.loc[isin, bus_col] = bus_idx.loc[name_vn_series.loc[isin]].values @@ -835,7 +845,7 @@ def _allocate_trafos_to_buses_and_create_buses( name=new_bus_data_dd.name, zone=new_bus_data_dd.TSO) trafo_connections.loc[need_bus_creation, bus_col] = net.bus.loc[new_bus_idx, [ "name", "vn_kv"]].reset_index().set_index(["name", "vn_kv"]).loc[list(new_bus_data[[ - "name", "vn_kv"]].itertuples(index=False, name=None))].values + "name", "vn_kv"]].itertuples(index=False, name=None))].values trafo_connections.loc[need_bus_creation, next_col] = \ trafo_connections.loc[need_bus_creation, trafo_vn_col].values trafo_connections.loc[need_bus_creation, rel_dev_col] = 0 @@ -913,9 +923,10 @@ def _find_trafo_locations(trafo_bus_names, bus_location_names): longest_part_in_buses = trafo_bus_names_longest_part.isin(bus_location_names) # --- check whether all name strings point at location names of the buses - if False: # for easy testing + if False: # for easy testing fail = ~(joined_in_buses | longest_part_in_buses) - a = pd.concat([trafo_bus_names_joined.loc[fail], trafo_bus_names_longest_part.loc[fail]], axis=1) + a = pd.concat([trafo_bus_names_joined.loc[fail], + trafo_bus_names_longest_part.loc[fail]], axis=1) if n_bus_names_not_found := len(joined_in_buses) - sum(joined_in_buses | longest_part_in_buses): raise ValueError( @@ -930,7 +941,7 @@ def _find_trafo_locations(trafo_bus_names, bus_location_names): return trafo_location_names -def _drop_duplicates_and_join_TSO(bus_df:pd.DataFrame) -> pd.DataFrame: +def _drop_duplicates_and_join_TSO(bus_df: pd.DataFrame) -> pd.DataFrame: bus_df = bus_df.drop_duplicates(ignore_index=True) # just keep one bus per name and vn_kv. If there are multiple buses of different TSOs, join the # TSO strings: @@ -945,12 +956,12 @@ def _get_float_column(df, col_tuple, fill=0): return series.astype(float).fillna(fill) -def _get_bus_idx(net:pandapowerNet) -> pd.Series: +def _get_bus_idx(net: pandapowerNet) -> pd.Series: return net.bus[["name", "vn_kv"]].rename_axis("index").reset_index().set_index([ "name", "vn_kv"])["index"] -def get_grid_groups(net:pandapowerNet, **kwargs) -> pd.DataFrame: +def get_grid_groups(net: pandapowerNet, **kwargs) -> pd.DataFrame: notravbuses_dict = dict() if "notravbuses" not in kwargs.keys() else { "notravbuses": kwargs.pop("notravbuses")} grid_group_buses = [set_ for set_ in connected_components(create_nxgraph(net, **kwargs), @@ -960,7 +971,7 @@ def get_grid_groups(net:pandapowerNet, **kwargs) -> pd.DataFrame: return grid_groups -def _lng_lat_to_df(dict_:dict, line_EIC:str, line_name:str) -> pd.DataFrame: +def _lng_lat_to_df(dict_: dict, line_EIC: str, line_name: str) -> pd.DataFrame: return pd.DataFrame([ [line_EIC, line_name, "from", "lng", dict_["lng"][0]], [line_EIC, line_name, "to", "lng", dict_["lng"][1]], @@ -969,10 +980,10 @@ def _lng_lat_to_df(dict_:dict, line_EIC:str, line_name:str) -> pd.DataFrame: ], columns=["EIC_Code", "name", "bus", "geo_dim", "value"]) -def _fill_geo_at_one_sided_branches_without_geo_extent(net:pandapowerNet): +def _fill_geo_at_one_sided_branches_without_geo_extent(net: pandapowerNet): - def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Union[pd.Index,int]]: - av = dict() # availablitiy of geodata + def _check_geo_availablitiy(net: pandapowerNet) -> dict[str, Union[pd.Index, int]]: + av = dict() # availablitiy of geodata av["bus_with_geo"] = net.bus.index[~net.bus.geo.isnull()] av["lines_fbw_tbwo"] = net.line.index[net.line.from_bus.isin(av["bus_with_geo"]) & (~net.line.to_bus.isin(av["bus_with_geo"]))] @@ -1001,7 +1012,7 @@ def _check_geo_availablitiy(net:pandapowerNet) -> dict[str, Union[pd.Index,int]] set_line_geodata_from_bus_geodata(net) -def _multi_str_repl(st:str, repl:list[tuple]) -> str: +def _multi_str_repl(st: str, repl: list[tuple]) -> str: for (old, new) in repl: return st.replace(old, new) @@ -1026,10 +1037,10 @@ def _multi_str_repl(st:str, repl:list[tuple]) -> str: pp_net_json_file = os.path.join(home, "desktop", "jao_grid.json") - if 1: # read from original data + if 1: # read from original data net = from_jao(excel_file_path, html_file_path, True, drop_grid_groups_islands=True) pp.to_json(net, pp_net_json_file) - else: # load net from already converted and stored net + else: # load net from already converted and stored net net = pp.from_json(pp_net_json_file) print(net) From 1687cf5e36fd04a81d8b29eb12040663515afb8d Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Fri, 6 Dec 2024 13:14:06 +0100 Subject: [PATCH 10/15] fixed copy-paste error in contingency results "max_limit_nminus1" and "min_limit_nminus1" --- CHANGELOG.rst | 1 + pandapower/contingency/contingency.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9fc265b8d..35ddfb6d8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ Change Log - [ADDED] Add GeographicalRegion and SubGeographicalRegion names and ids to bus df in cim converter - [CHANGED] Capitalize first letter of columns busbar_id, busbar_name and substation_id in bus df for cim converter - [FIXED] Do not modify pandas options when importing pandapower +- [FIXED] fixed copy-paste error in contingency results "max_limit_nminus1" and "min_limit_nminus1" - [ADDED] improved lightsim2grid documentation including compatibitliy issues - [FIXED] cim2pp: set default xml encoding to None to avoid error after changing to lxml diff --git a/pandapower/contingency/contingency.py b/pandapower/contingency/contingency.py index 6b6b51d7e..edcaf2398 100644 --- a/pandapower/contingency/contingency.py +++ b/pandapower/contingency/contingency.py @@ -362,11 +362,11 @@ def get_element_limits(net): "max_limit": net.bus.loc[bus_index, "max_vm_pu"].values, "min_limit": net.bus.loc[bus_index, "min_vm_pu"].values, "max_limit_nminus1": - net.line.loc[bus_index, "max_vm_nminus1_pu"].values + net.bus.loc[bus_index, "max_vm_nminus1_pu"].values if "max_vm_nminus1_pu" in net.bus.columns else net.bus.loc[bus_index, "max_vm_pu"].values, "min_limit_nminus1": - net.line.loc[bus_index, "min_vm_nminus1_pu"].values + net.bus.loc[bus_index, "min_vm_nminus1_pu"].values if "min_vm_nminus1_pu" in net.bus.columns else net.bus.loc[bus_index, "min_vm_pu"].values}}) From 08bd19a999e066cb6043bf564716d8f19eed2da2 Mon Sep 17 00:00:00 2001 From: Joschka Thurner Date: Fri, 6 Dec 2024 14:38:14 +0100 Subject: [PATCH 11/15] fix sphinx build test --- .github/workflows/github_test_action.yml | 16 ++++------------ doc/requirements.txt | 3 --- pyproject.toml | 4 ++-- 3 files changed, 6 insertions(+), 17 deletions(-) delete mode 100644 doc/requirements.txt diff --git a/.github/workflows/github_test_action.yml b/.github/workflows/github_test_action.yml index a816bec26..c6b0d76f8 100644 --- a/.github/workflows/github_test_action.yml +++ b/.github/workflows/github_test_action.yml @@ -301,21 +301,13 @@ jobs: python -m pytest -W error --nbmake -n=auto --nbmake-timeout=900 "./tutorials" docs_check: + name: Sphinx docs check runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ '3.9' ] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Check docs for Python ${{ matrix.python-version }} - uses: e2nIEE/sphinx-action@master + - name: Check sphinx build + uses: ammaraskar/sphinx-action@7.4.7 with: - pre-build-command: "apt update && apt upgrade -y && apt install -y build-essential gfortran cmake pkg-config libopenblas-dev; - python -m pip install --upgrade pip; - python -m pip install .[docs];" + pre-build-command: "python -m pip install uv && uv pip install .[docs] --system --link-mode=copy" build-command: "sphinx-build -b html . _build -W" docs-folder: "doc/" diff --git a/doc/requirements.txt b/doc/requirements.txt deleted file mode 100644 index 2433ee980..000000000 --- a/doc/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinx>=5.3.0 -sphinx_rtd_theme>=1.1.1 -numpydoc>=1.5.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 98b08cea7..f3906d484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ Download = "https://pypi.org/project/pandapower/#files" Changelog = "https://github.com/e2nIEE/pandapower/blob/develop/CHANGELOG.rst" [project.optional-dependencies] -docs = ["numpydoc", "matplotlib", "sphinx", "sphinx_rtd_theme", "sphinx-pyproject"] +docs = ["numpydoc>=1.5.0", "matplotlib", "sphinx>=5.3.0", "sphinx_rtd_theme>=1.1.1", "sphinx-pyproject"] plotting = ["plotly>=3.1.1", "matplotlib", "igraph", "geopandas>=1.0"] test = ["pytest~=8.1", "pytest-xdist", "nbmake"] performance = ["ortools", "numba>=0.25", "lightsim2grid==0.9.0"] @@ -68,7 +68,7 @@ converter = ["matpowercaseframes"] pgm = ["power-grid-model-io"] control = ["shapely"] all = [ - "numpydoc", "sphinx", "sphinx_rtd_theme", "sphinx-pyproject", + "numpydoc>=1.5.0", "sphinx>=5.3.0", "sphinx_rtd_theme>=1.1.1", "sphinx-pyproject", "plotly>=3.1.1", "matplotlib", "igraph", "geopandas>=1.0", "pytest~=8.1", "pytest-xdist", "nbmake", "ortools", "numba>=0.25", "lightsim2grid==0.9.0", From 02376b06f9ba346f8d6bcec768797308251fd15a Mon Sep 17 00:00:00 2001 From: Joschka Thurner Date: Mon, 9 Dec 2024 09:29:26 +0100 Subject: [PATCH 12/15] switch to uv in github test workflow --- .github/workflows/github_test_action.yml | 151 +++++++++++------------ 1 file changed, 70 insertions(+), 81 deletions(-) diff --git a/.github/workflows/github_test_action.yml b/.github/workflows/github_test_action.yml index a816bec26..08bc69abf 100644 --- a/.github/workflows/github_test_action.yml +++ b/.github/workflows/github_test_action.yml @@ -25,31 +25,31 @@ jobs: steps: - uses: actions/checkout@v4 #- uses: julia-actions/setup-julia@v1.5 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest pytest-split - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - python -m pip install .["all"] - if ${{ matrix.python-version == '3.9' }}; then python -m pip install pypower; fi - if ${{ matrix.python-version != '3.9' }}; then python -m pip install numba; fi - if ${{ matrix.python-version == '3.10' }}; then python -m pip install lightsim2grid; fi + uv sync --all-extras + if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi + uv pip install pytest-split + if ${{ matrix.python-version == '3.9' }}; then uv pip install pypower; fi + if ${{ matrix.python-version != '3.9' }}; then uv pip install numba; fi + if ${{ matrix.python-version == '3.10' }}; then uv pip install lightsim2grid; fi - name: List of installed packages run: | - python -m pip list + uv pip list - name: Test with pytest if: ${{ matrix.python-version != '3.9' }} run: | - python -m pytest --splits 2 --group ${{ matrix.group }} + uv run pytest --splits 2 --group ${{ matrix.group }} - name: Test with pytest, Codecov and Coverage if: ${{ matrix.python-version == '3.9' }} run: | - python -m pip install pytest-cov - python -m pytest -n=auto --cov=./ --cov-report=xml --splits 2 --group ${{ matrix.group }} + uv pip install pytest-cov + uv run pytest -n=auto --cov=./ --cov-report=xml --splits 2 --group ${{ matrix.group }} cp ./coverage.xml ./coverage-${{ matrix.group }}.xml - name: Upload coverage as artifact if: ${{ matrix.python-version == '3.9' }} @@ -71,29 +71,27 @@ jobs: steps: - uses: actions/checkout@v4 #- uses: julia-actions/setup-julia@v1.5 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest pytest-split - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - python -m pip install .["all"] - python -m pip install pypower + uv sync --all-extras + uv pip install pypower pytest-split + if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi - name: Install Julia run: | ./.install_julia.sh 1.10.4 - python -m pip install julia - python ./.install_pycall.py + uv pip install julia + uv run python ./.install_pycall.py - name: List of installed packages run: | - python -m pip list + uv pip list - name: Test with pytest, Codecov and Coverage run: | - python -m pip install pytest-cov - python -m pytest -n=auto --cov=./ --cov-report=xml --splits 2 --group ${{ matrix.group }} + uv pip install pytest-cov + uv run pytest -n=auto --cov=./ --cov-report=xml --splits 2 --group ${{ matrix.group }} cp ./coverage.xml ./coverage-${{ matrix.group }}.xml upload-coverage: @@ -137,22 +135,21 @@ jobs: group: [ 1, 2 ] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest pytest-split - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - python -m pip install .["all"] + uv sync --all-extras + uv pip install pytest-split + if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi - name: List of installed packages run: | - python -m pip list + uv pip list - name: Test with pytest run: | - python -m pytest -W error --splits 2 --group ${{ matrix.group }} + uv run pytest -W error --splits 2 --group ${{ matrix.group }} relying: # packages that rely on pandapower runs-on: ubuntu-latest @@ -161,31 +158,30 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest setuptools - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - python -m pip install . - python -m pip install matplotlib - if ${{ matrix.python-version != '3.9' }}; then python -m pip install numba; fi + uv sync + uv pip install setuptools + if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi + uv pip install matplotlib + if ${{ matrix.python-version != '3.9' }}; then uv pip install numba; fi - name: Install pandapipes and simbench run: | - python -m pip install git+https://github.com/e2nIEE/pandapipes@develop#egg=pandapipes - python -m pip install git+https://github.com/e2nIEE/simbench@develop#egg=simbench + uv pip install git+https://github.com/e2nIEE/pandapipes@develop#egg=pandapipes + uv pip install git+https://github.com/e2nIEE/simbench@develop#egg=simbench - name: List of installed packages run: | - python -m pip list + uv pip list - name: Test pandapipes run: | - python -c 'from pandapipes import pp_dir; import pytest; import sys; ec = pytest.main([pp_dir]); sys.exit(ec)' + uv run python -c 'from pandapipes import pp_dir; import pytest; import sys; ec = pytest.main([pp_dir]); sys.exit(ec)' - name: Test simbench run: | - python -c 'from simbench import sb_dir; import pytest; import sys; ec = pytest.main([sb_dir]); sys.exit(ec)' + uv run python -c 'from simbench import sb_dir; import pytest; import sys; ec = pytest.main([sb_dir]); sys.exit(ec)' linting: # run flake8 and check for errors @@ -197,28 +193,26 @@ jobs: python-version: ['3.10'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install flake8 - python -m pip install . - python -m pip install matplotlib + uv sync + uv pip install flake8 matplotlib - name: List of installed packages run: | - python -m pip list + uv pip list - name: Lint with flake8 (sytax errors and undefined names) continue-on-error: true run: | # stop the build if there are Python syntax errors or undefined names (omitted by exit-zero) - flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics + uv run flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics - name: Lint with flake8 (all errors and warnings) run: | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + uv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics postgresql: # for the one test to cover postgresql @@ -228,17 +222,16 @@ jobs: python-version: ['3.12'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install .[test,fileio] + uv sync --extra test --extra fileio - name: List of installed packages run: | - python -m pip list + uv pip list - name: Create PostgreSQL database run: | sudo systemctl start postgresql.service @@ -249,56 +242,52 @@ jobs: PGPASSWORD=secret psql --username=test_user --host=localhost --list sandbox - name: Test pandapower File I/O run: | - python -c "import os; import json; from pandapower import pp_dir; conn_data={'host': 'localhost', 'user': 'test_user', 'database': 'sandbox', 'password': 'secret', 'schema': 'test_schema'}; fp = open(os.path.join(pp_dir, 'test', 'test_files', 'postgresql_connect_data.json'), 'w'); json.dump(conn_data, fp); fp.close()" - python -c 'from pandapower import pp_dir; import pytest; import sys; import os; ec = pytest.main([os.path.join(pp_dir,"test","api","test_sql_io.py")]); sys.exit(ec)' + uv run python -c "import os; import json; from pandapower import pp_dir; conn_data={'host': 'localhost', 'user': 'test_user', 'database': 'sandbox', 'password': 'secret', 'schema': 'test_schema'}; fp = open(os.path.join(pp_dir, 'test', 'test_files', 'postgresql_connect_data.json'), 'w'); json.dump(conn_data, fp); fp.close()" + uv run python -c 'from pandapower import pp_dir; import pytest; import sys; import os; ec = pytest.main([os.path.join(pp_dir,"test","api","test_sql_io.py")]); sys.exit(ec)' tutorial_tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: '3.11' - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest nbmake pytest-xdist igraph numba seaborn + uv sync --all-extras + uv pip install seaborn ./.install_julia.sh 1.10.4 - python -m pip install julia - python ./.install_pycall.py - python -m pip install jupyter - python -m pip install .["all"] + uv pip install julia seaborn jupyter + uv run python ./.install_pycall.py - name: List all installed packages run: | - python -m pip list + uv pip list - name: Test with pytest # Careful when copying this command. The PYTHONPATH setup is Linux specific syntax. run: | - python -m pytest --nbmake -n=auto --nbmake-timeout=900 "./tutorials" + uv run pytest --nbmake -n=auto --nbmake-timeout=900 "./tutorials" tutorial_warnings_tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a #v4.2.0 with: python-version: '3.11' - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install .[all] - python -m pip install pytest nbmake pytest-xdist igraph numba seaborn + uv sync --all-extras ./.install_julia.sh 1.10.4 - python -m pip install julia - python ./.install_pycall.py + uv pip install julia seaborn + uv run python ./.install_pycall.py - name: List all installed packages run: | - python -m pip list + uv pip list - name: Test with pytest run: | - python -m pytest -W error --nbmake -n=auto --nbmake-timeout=900 "./tutorials" + uv run pytest -W error --nbmake -n=auto --nbmake-timeout=900 "./tutorials" docs_check: runs-on: ubuntu-latest From d567b5e95c1de139a239bf7867216b93c76b66a4 Mon Sep 17 00:00:00 2001 From: Joschka Thurner Date: Mon, 9 Dec 2024 09:45:07 +0100 Subject: [PATCH 13/15] exclude .venv from flake --- .github/workflows/github_test_action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github_test_action.yml b/.github/workflows/github_test_action.yml index 08bc69abf..37757eb59 100644 --- a/.github/workflows/github_test_action.yml +++ b/.github/workflows/github_test_action.yml @@ -208,11 +208,11 @@ jobs: continue-on-error: true run: | # stop the build if there are Python syntax errors or undefined names (omitted by exit-zero) - uv run flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics + uv run flake8 . --exclude .venv --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics - name: Lint with flake8 (all errors and warnings) run: | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - uv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + uv run flake8 . --exclude .venv --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics postgresql: # for the one test to cover postgresql From 679d25b34d57e2d3cfe784f788ec57504c886acf Mon Sep 17 00:00:00 2001 From: Joschka Thurner Date: Mon, 9 Dec 2024 10:37:09 +0100 Subject: [PATCH 14/15] install test group in relying tests --- .github/workflows/github_test_action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github_test_action.yml b/.github/workflows/github_test_action.yml index 37757eb59..fec7b0fef 100644 --- a/.github/workflows/github_test_action.yml +++ b/.github/workflows/github_test_action.yml @@ -164,7 +164,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - uv sync + uv sync --extra test uv pip install setuptools if [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi uv pip install matplotlib From 119eb517accd3b9a4b19d8f2991a3011eb1e7c50 Mon Sep 17 00:00:00 2001 From: Steffen Meinecke Date: Wed, 11 Dec 2024 17:59:54 +0100 Subject: [PATCH 15/15] merge --- CHANGELOG.rst | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 885dfa779..cbf1d08a0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,21 +12,6 @@ Change Log - [FIXED] fixed copy-paste error in contingency results "max_limit_nminus1" and "min_limit_nminus1" - [ADDED] improved lightsim2grid documentation including compatibitliy issues - [FIXED] cim2pp: set default xml encoding to None to avoid error after changing to lxml - -[2.14.11] - 2024-07-08 -------------------------------- -- [FIXED] Lightsim2grid version - -[2.14.10] - 2024-07-08 -------------------------------- -- [FIXED] geopandas version - -[2.14.9] - 2024-06-25 -------------------------------- -- [FIXED] scipy version - -[upcoming release] - 2024-..-.. -------------------------------- - [FIXED] PandaModels OPF with 'bus_dc' key errors - [FIXED] julia tests - [FIXED] copy array element to standard python scalar @@ -103,6 +88,18 @@ Change Log - [ADDED] converter for European EHV grid data from JAO, the "Single Allocation Platform (SAP) for all European Transmission System Operators (TSOs) that operate in accordance to EU legislation" - [ADDED] cim2pp converter: Using lxml to parse XML files (better performance) +[2.14.11] - 2024-07-08 +------------------------------- +- [FIXED] Lightsim2grid version + +[2.14.10] - 2024-07-08 +------------------------------- +- [FIXED] geopandas version + +[2.14.9] - 2024-06-25 +------------------------------- +- [FIXED] scipy version + [2.14.7] - 2024-06-14 ------------------------------- - [ADDED] added PathPatch TextPatch and Affine2D imports needed for ward and xward patches