diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f28d9134bd..bb5af3dc70 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,12 +3,20 @@ Change Log [upcoming release] - 2026-..-.. ------------------------------- +- [FIXED] runopp(init="results") now preserves the warm-start vector in the PIPS-backed AC OPF solver +- [ADDED] added more functions to diagnostic +- [ADDED] check to check if vkr_percent values are reasonable (see issue #786). - [FIXED] cim2pp shift_lv_degree was translated from wrong entry - [FIXED] UnboundLocalError in _from_ppc_branch when creating impedance elements - [ADDED] LTDS support +- [FIXED] ucte2pp: voltage setpoints from gens connected to the same busbar are now averaged +- [FIXED] ucte2pp: small X values are clipped to 0.05 Ohm (according to UCTE-DEF) to increase convergence +- [FIXED] ucte2pp: symmetrical tap changers are now handled as symmetrical tap changers in pandapower (not ideal phase shifters) +- [FIXED] ucte2pp: prevent nan values for impedances and transformers for B/G/P_fe/i0 - [FIXED] cim2pp: CimConverter backwards-compatible (default value for cin_version) - [FIXED] jao converter: calculation of trafo parameters is based on primary side (hv) now - [ADDED] toolbox: :code:`get_all_elements` returns all elements of a pp.pandapowerNet as a DataFrame +- [ADDED] highlighting feature and hovering functionality to :code:`simple_plot()` [3.4.0] - 2026-02-09 ------------------------------- diff --git a/doc/conf.py b/doc/conf.py index 02b6ffbca9..e260cc384c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -24,7 +24,7 @@ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' -sys.path.append(os.path.abspath("..")) +sys.path.insert(0, os.path.abspath("..")) sys.path.append(os.path.abspath(".\\_themes")) sys.path.append(os.path.abspath("..\\tests")) sys.path.append(os.path.abspath("..\\network_generator")) diff --git a/doc/elements/motor_par.csv b/doc/elements/motor_par.csv index 7169c19714..7f488720c2 100644 --- a/doc/elements/motor_par.csv +++ b/doc/elements/motor_par.csv @@ -6,7 +6,7 @@ cos_phi *;float;:math:`0...1`;cosine phi at current operating point cos_phi_n *;float;:math:`0...1`;cosine phi at rated power of the motor for short-circuit calculation efficiency_percent *;float;:math:`0..100`; Efficiency in percent at current operating point[%] efficiency_n_percent *;float;:math:`0..100`; Efficiency in percent at rated power for short-circuit calculation [%] -loading_percent *;float;:math:`0..100`; Efficiency in percent at rated power for short-circuit calculation [%] [%] +loading_percent *;float;:math:`0..100`; The mechanical loading in percentage of the rated mechanical power [%] scaling *;float;:math:`\geq 0`;scaling factor for active and reactive power lrc_pu *;float;:math:`\geq 0`; locked rotor current in relation to the rated motor current [pu] rx *;float;:math:`\geq 0`;R/X ratio of the motor for short-circuit calculation. diff --git a/doc/pics/plotting/simple_hl_plot_mv_obi.png b/doc/pics/plotting/simple_hl_plot_mv_obi.png new file mode 100644 index 0000000000..932ec42f11 Binary files /dev/null and b/doc/pics/plotting/simple_hl_plot_mv_obi.png differ diff --git a/doc/plotting/matplotlib/simple_plot.rst b/doc/plotting/matplotlib/simple_plot.rst index b620c38f45..802fd0e82f 100644 --- a/doc/plotting/matplotlib/simple_plot.rst +++ b/doc/plotting/matplotlib/simple_plot.rst @@ -2,6 +2,9 @@ Simple Plotting ============================= +Simple Network Plot +============================== + The function simple_plot() can be used for simple plotting. For advanced possibilities see the `tutorial `__. @@ -16,3 +19,23 @@ A helper function for angle calculation is provided. It will use all elements in a network to calculate angles for each patch based on the amount of elements at each bus. .. autofunction:: pandapower.plotting.calculate_unique_angles + +Simple Highlighting Plot +============================== + +The function ``simple_plot()`` can also highlight lines or buses in a simple network plot. The highlighted +elements are displayed in red and enlarged. Additionally, buses and lines can be located directly +in the plot by hovering the mouse over a specific line or bus. The ``name`` and ``index`` will be shown in +a small box: :: + + net = mv_oberrhein() + ol_lines = net.line.loc[net.line.type=="ol"].index + ol_buses = net.bus.index[net.bus.index.isin(net.line.from_bus.loc[ol_lines]) | + net.bus.index.isin(net.line.to_bus.loc[ol_lines])] + + simple_plot(net, hl_lines=ol_lines, hl_buses=ol_buses, enable_hovering=True) + + +.. image:: /pics/plotting/simple_hl_plot_mv_obi.png + :width: 80em + :align: left diff --git a/pandapower/_version.py b/pandapower/_version.py index b842d32a3f..0d0f1cb5e0 100644 --- a/pandapower/_version.py +++ b/pandapower/_version.py @@ -1,2 +1,2 @@ -__version__ = "3.4.0" -__format_version__ = "3.1.0" +__version__ = "4.0.0" +__format_version__ = "4.0.0" diff --git a/pandapower/auxiliary.py b/pandapower/auxiliary.py index 6ca526a6ac..0afa2bf919 100644 --- a/pandapower/auxiliary.py +++ b/pandapower/auxiliary.py @@ -75,6 +75,8 @@ geopandas_available = True except ImportError: geopandas_available = False + # for typing only + GeoSeries = object PyPowerNetwork = dict[str, Any] @@ -86,6 +88,7 @@ T = TypeVar("T") +# FIXME: Remove this! def log_to_level( msg: str, passed_logger: logging.Logger, @@ -110,6 +113,7 @@ def version_check( level: Literal["error", "warning", "info", "debug", "UserWarning"] = "UserWarning", ignore_not_installed: bool = False ) -> None: + # FIXME: version should NEVER be defined in code! minimum_version = {'plotly': "3.1.1", 'numba': "0.25", } @@ -479,7 +483,7 @@ def __init__(self, *args: Any, **kwargs: Any): @classmethod def create_dataframes(cls, data): - for key in data: #TODO: change index dtype to np.uint32 + for key in data: if isinstance(data[key], dict): data[key] = pd.DataFrame(columns=data[key].keys(), index=pd.Index([], dtype=np.int64)).astype(data[key]) return data @@ -492,7 +496,7 @@ def __repr__(self) -> str: # pragma: no cover """ par = [] res = [] - for et in list(self.keys()): + for et in self.keys(): if not et.startswith("_") and isinstance(self[et], pd.DataFrame) and len(self[et]) > 0: n_rows = self[et].shape[0] if 'res_' in et: @@ -653,6 +657,8 @@ def element_types_to_ets(element_types: ElementType | list[ElementType] | None = def empty_defaults_per_dtype(dtype: np.dtype[Any]) -> Any: if is_numeric_dtype(dtype): return np.nan + elif isinstance(dtype, pd.StringDtype): + return pd.NA elif is_string_dtype(dtype): return "" elif is_object_dtype(dtype): @@ -662,12 +668,10 @@ def empty_defaults_per_dtype(dtype: np.dtype[Any]) -> Any: def _preserve_dtypes(df: pd.DataFrame, dtypes: pdt.Series[np.dtype[Any]]) -> None: - for item, dtype in list(dtypes.items()): + for item, dtype in dtypes.items(): if df.dtypes.at[item] != dtype: if (dtype == bool or dtype == np.bool_) and np.any(df[item].isnull()): - raise UserWarning(f"Encountered NaN value(s) in a boolean column {item}! " - f"NaN are casted to True by default, which can lead to errors. " - f"Replace NaN values with True or False first.") + df[item] = df[item].fillna(pd.NA).astype('boolean') try: df[item] = df[item].astype(dtype) except ValueError: @@ -997,7 +1001,10 @@ def _detect_read_write_flag( # read functions: def _read_from_single_index(net: pandapowerNet, element: str, variable: str, index: np.int64) -> Any: - return net[element].at[index, variable] + if variable in net[element]: + return net[element].at[index, variable] + else: + return pd.NA def _read_from_all_index(net: pandapowerNet, element: str, variable: str) -> NDArray[Any]: @@ -1316,11 +1323,10 @@ def _select_is_elements_numba( ppc_bus_isolated = np.zeros(ppc["bus"].shape[0], dtype=bool) ppc_bus_isolated[isolated_nodes] = True set_isolated_buses_oos(bus_in_service, ppc_bus_isolated, net["_pd2ppc_lookups"]["bus"]) - # mode = net["_options"]["mode"] elements_ac = ["load", "motor", "sgen", "asymmetric_load", "asymmetric_sgen", "gen", "ward", "xward", "shunt", "ext_grid", "storage", "svc", "ssc", "vsc"] # ,"impedance_load" elements_dc = ["vsc", "load_dc", "source_dc"] - is_elements = dict() + is_elements = {} for element_table_list, bus_table, bis in zip((elements_ac, elements_dc), ("bus", "bus_dc"), (bus_in_service, bus_dc_in_service)): for element_table in element_table_list: num_elements = len(net[element_table].index) @@ -1404,8 +1410,6 @@ def _add_ppc_options( """ creates dictionary for pf, opf and short circuit calculations from input parameters. """ - # if recycle is None: - # recycle = dict(trafo=False, bus_pq=False, bfsw=False) init_results = (isinstance(init_vm_pu, str) and (init_vm_pu == "results")) or \ (isinstance(init_va_degree, str) and (init_va_degree == "results")) @@ -1544,13 +1548,11 @@ def _add_sc_options( def _add_options(net: pandapowerNet, options: dict[str, Any]) -> None: - # double_parameters = set(net.__internal_options.keys()) & set(options.keys()) double_parameters = set(net._options.keys()) & set(options.keys()) if len(double_parameters) > 0: raise UserWarning( "Parameters always have to be unique! The following parameters where specified " + "twice: %s" % double_parameters) - # net.__internal_options.update(options) net._options.update(options) @@ -1953,7 +1955,11 @@ def _add_dcline_gens(net: pandapowerNet) -> None: p_mw = np.abs(dctab.p_mw) p_loss = p_mw * (1 - dctab.loss_percent / 100) - dctab.loss_mw # type: ignore[operator] - max_p_mw: float = dctab.max_p_mw # type: ignore[assignment] + max_p_mw: float + if hasattr(dctab, 'max_p_mw') and pd.notna(dctab.max_p_mw): + max_p_mw = dctab.max_p_mw # type: ignore[assignment] + else: + max_p_mw = float('nan') p_min: float p_max: float if np.sign(dctab.p_mw) > 0: @@ -1967,15 +1973,41 @@ def _add_dcline_gens(net: pandapowerNet) -> None: p_max = 0 p_min = -max_p_mw - create_gen(net, bus=dctab.to_bus, p_mw=p_to, vm_pu=dctab.vm_to_pu, - min_p_mw=p_min, max_p_mw=p_max, - max_q_mvar=dctab.max_q_to_mvar, min_q_mvar=dctab.min_q_to_mvar, - in_service=dctab.in_service) - - create_gen(net, bus=dctab.from_bus, p_mw=p_from, vm_pu=dctab.vm_from_pu, - min_p_mw=-p_max, max_p_mw=-p_min, - max_q_mvar=dctab.max_q_from_mvar, min_q_mvar=dctab.min_q_from_mvar, - in_service=dctab.in_service) + kwargs_to = { + 'bus': dctab.to_bus, + 'p_mw': p_to, + 'vm_pu': dctab.vm_to_pu, + 'in_service': dctab.in_service + } + + if hasattr(dctab, 'min_p_mw'): + kwargs_to['min_p_mw'] = p_min + if hasattr(dctab, 'max_p_mw'): + kwargs_to['max_p_mw'] = p_max + if hasattr(dctab, 'max_q_to_mvar'): + kwargs_to['max_q_mvar'] = dctab.max_q_to_mvar + if hasattr(dctab, 'min_q_to_mvar'): + kwargs_to['min_q_mvar'] = dctab.min_q_to_mvar + + create_gen(net, **kwargs_to) + + kwargs_from = { + 'bus': dctab.from_bus, + 'p_mw': p_from, + 'vm_pu': dctab.vm_from_pu, + 'in_service': dctab.in_service + } + + if hasattr(dctab, 'max_p_mw'): + kwargs_from['min_p_mw'] = -p_max + if hasattr(dctab, 'min_p_mw'): + kwargs_from['max_p_mw'] = -p_min + if hasattr(dctab, 'max_q_from_mvar'): + kwargs_from['max_q_mvar'] = dctab.max_q_from_mvar + if hasattr(dctab, 'min_q_from_mvar'): + kwargs_from['min_q_mvar'] = dctab.min_q_from_mvar + + create_gen(net, **kwargs_from) def _add_vsc_stacked(net: pandapowerNet): @@ -2093,7 +2125,7 @@ def _init_runpp_options( lightsim2grid = kwargs.get("lightsim2grid", "auto") # for all the parameters from 'overrule_options' we need to collect them - # if they are used for any of the chjecks below: + # if they are used for any of the checks below: algorithm = overrule_options.get("algorithm", algorithm) calculate_voltage_angles = overrule_options.get("calculate_voltage_angles", calculate_voltage_angles) init = overrule_options.get("init", init) @@ -2107,15 +2139,11 @@ def _init_runpp_options( # tolerance_mva, trafo_model, trafo_loading, enforce_p_lims, enforce_q_lims, check_connectivity, consider_line_temperature # check if numba is available and the corresponding flag - if numba: - numba = _check_if_numba_is_installed() + numba &= _check_if_numba_is_installed() - if voltage_depend_loads: - if not (np.any(net["load"]["const_z_p_percent"].values) - or np.any(net["load"]["const_i_p_percent"].values) - or np.any(net["load"]["const_z_q_percent"].values) - or np.any(net["load"]["const_i_q_percent"].values)): - voltage_depend_loads = False + cols = {"const_z_p_percent", "const_i_p_percent", "const_z_q_percent", "const_i_q_percent"} + # if const parameters are not set voltage_depend_loads is deactivated + voltage_depend_loads &= bool(cols.issubset(net.load.columns) and net.load[list(cols)].any().any()) lightsim2grid = _check_lightsim2grid_compatibility(net, lightsim2grid, voltage_depend_loads, algorithm, distributed_slack, tdpf) @@ -2320,7 +2348,6 @@ def _init_rundcopp_options( # scipy spsolve options in NR power flow use_umfpack = kwargs.get("use_umfpack", True) permc_spec = kwargs.get("permc_spec", None) - # net.__internal_options = {} net._options = {} _add_ppc_options(net, calculate_voltage_angles=calculate_voltage_angles, trafo_model=trafo_model, check_connectivity=check_connectivity, diff --git a/pandapower/build_branch.py b/pandapower/build_branch.py index aac76dba43..7778e1ca2f 100644 --- a/pandapower/build_branch.py +++ b/pandapower/build_branch.py @@ -11,7 +11,7 @@ from typing import Any, Optional import numpy as np -from numpy.typing import NDArray +from numpy.typing import NDArray, ArrayLike import pandas as pd from pandapower.auxiliary import get_values, pandapowerNet @@ -28,6 +28,7 @@ from pandapower.pypower.idx_bus_sc import C_MIN, C_MAX from pandapower.pypower.idx_tcsc import TCSC_F_BUS, TCSC_T_BUS, TCSC_X_L, TCSC_X_CVAR, TCSC_SET_P, \ TCSC_THYRISTOR_FIRING_ANGLE, TCSC_STATUS, TCSC_CONTROLLABLE, tcsc_cols, TCSC_MIN_FIRING_ANGLE, TCSC_MAX_FIRING_ANGLE +from pandapower.create._utils import add_column_to_df def _build_branch_ppc(net, ppc, sequence=1): @@ -401,12 +402,12 @@ def _calc_trafo_parameter(net, ppc, sequence=1): def get_trafo_values(trafo_df: pd.DataFrame | dict, column: str, na_replacement: Any = pd.NA) -> Optional[NDArray]: """ Get values from dataframe. - + Parameters: trafo_df: The DataFrame from which to get the column column: column name to get. na_replacement: Element to replace pd.NA with. - + Returns: None if column not found in trafo_df or NDArray of the column where pd.NA is replaced by na_replacement. """ @@ -487,11 +488,11 @@ def _calc_r_x_y_from_dataframe(net, trafo_df, vn_trafo_lv, vn_lv, ppc, sequence= else: r, x = _calc_r_x_from_dataframe(mode, trafo_df, vn_lv, vn_trafo_lv, net.sn_mva, sequence=sequence) else: - warnings.warn(DeprecationWarning("tap_dependency_table is missing in net, which is most probably due to " - "unsupported net data. tap_dependency_table was introduced with " - "pandapower 3.0 and replaced spline characteristics. Spline " - "characteristics will still work, but they are deprecated and will be " - "removed in future releases.")) + warnings.warn(DeprecationWarning( + "tap_dependency_table is missing in net, which is most probably due to unsupported net data. " + "tap_dependency_table was introduced with pandapower 3.0 and replaced spline characteristics. " + "Spline characteristics will still work, but they are deprecated and will be removed in future releases." + )) r, x = _calc_r_x_from_dataframe( mode, trafo_df, vn_lv, vn_trafo_lv, net.sn_mva, sequence=sequence, characteristic=net.get("characteristic") ) @@ -517,10 +518,10 @@ def _calc_r_x_y_from_dataframe(net, trafo_df, vn_trafo_lv, vn_lv, ppc, sequence= if trafo_model == "pi": return r, x, g, b, 0, 0 # g_asym and b_asym are 0 here elif trafo_model == "t": - r_ratio = get_trafo_values(trafo_df, "leakage_resistance_ratio_hv") + r_ratio = get_trafo_values(trafo_df, "leakage_resistance_ratio_hv", na_replacement=0.5) if r_ratio is None: r_ratio = np.full_like(r, fill_value=0.5, dtype=np.float64) - x_ratio = get_trafo_values(trafo_df, "leakage_reactance_ratio_hv") + x_ratio = get_trafo_values(trafo_df, "leakage_reactance_ratio_hv", na_replacement=0.5) if x_ratio is None: x_ratio = np.full_like(r, fill_value=0.5, dtype=np.float64) @@ -632,11 +633,12 @@ def _calc_tap_from_dataframe(net, trafo_df): tap_side = get_trafo_values(trafo_df, f"tap{t}_side", na_replacement='') tap_step_percent = get_trafo_values(trafo_df, f"tap{t}_step_percent", na_replacement=float('nan')) - tap_changer_type = get_trafo_values(trafo_df, f"tap{t}_changer_type", na_replacement='') + tap_changer_type = get_trafo_values(trafo_df, f"tap{t}_changer_type", na_replacement="") if tap_changer_type is not None: # tap_changer_type is only in dataframe starting from pp Version 3.0, older version use different logic if f'tap{t}_dependency_table' in trafo_df: - tap_dependency_table = get_trafo_values(trafo_df, "tap_dependency_table", na_replacement=False) + tap_dependency_table = get_trafo_values( + trafo_df, "tap_dependency_table", na_replacement=False).astype(bool) else: tap_dependency_table = np.array([False]) tap_table = np.logical_and(tap_dependency_table, tap_changer_type is not None) @@ -658,7 +660,7 @@ def _calc_tap_from_dataframe(net, trafo_df): filtered_df = net.trafo_characteristic_table.merge(filter_df[filter_df['mask']], on=['id_characteristic', 'step']) - cleaned_id_characteristic = id_characteristic_table[(~pd.isna(id_characteristic_table)) & mask] + cleaned_id_characteristic = id_characteristic_table[~pd.isna(id_characteristic_table) & mask] voltage_mapping = dict(zip(filtered_df['id_characteristic'], filtered_df['voltage_ratio'])) shift_mapping = dict(zip(filtered_df['id_characteristic'], filtered_df['angle_deg'])) @@ -751,20 +753,20 @@ def _get_trafo_shift(trafo_df, tap, mask, direction, vn=None, ideal=True): For ideal tap changers: - If tap_step_degree is set: shift = direction * tap_diff * tap_step_degree - If tap_step_percent is set: shift = direction * 2 * arcsin(tap_diff * tap_step_percent / 100 / 2) - + For complex tap changers: - Performs detailed voltage triangle calculations considering both magnitude and angle changes """ - def _cos(x): + def _cos(x: ArrayLike) -> ArrayLike: return np.cos(np.deg2rad(x)) - def _sin(x): + def _sin(x: ArrayLike) -> ArrayLike: return np.sin(np.deg2rad(x)) - def _arcsin(x): + def _arcsin(x: ArrayLike) -> ArrayLike: return np.rad2deg(np.arcsin(x)) - def _arctan(x): + def _arctan(x: ArrayLike) -> ArrayLike: return np.rad2deg(np.arctan(x)) if vn is None and not ideal: @@ -795,13 +797,13 @@ def _arctan(x): if (degree_is_set & percent_is_set).any(): raise UserWarning( "Both tap_step_degree and tap_step_percent set for ideal phase shifter") - + return np.where( degree_is_set, (direction * tap_diff * tap_step_degree), (direction * 2 * _arcsin(tap_diff * tap_step_percent / 100 / 2)) ), None - + # complex tap changer tap_steps = tap_step_percent * tap_diff / 100 tap_angles = np.nan_to_num(tap_step_degree, nan=0) @@ -811,7 +813,7 @@ def _arctan(x): return _arctan(direction * du * _sin(tap_angles) / (u1 + du * _cos(tap_angles))), _vn_modified -# FIXME: sideeffect: overwrites data in trafo_df with data from trafo_characterisitc_table +# FIXME: sideeffect: overwrites data in trafo_df with data from trafo_characteristic_table def _get_vk_values_from_table(trafo_df, trafo_characteristic_table, trafotype="2W"): if trafotype == "2W": vk_variables = ("vk_percent", "vkr_percent") @@ -821,13 +823,13 @@ def _get_vk_values_from_table(trafo_df, trafo_characteristic_table, trafotype="2 else: raise UserWarning("Unknown trafotype") - tap_dependency_table = get_trafo_values(trafo_df, "tap_dependency_table") + tap_dependency_table = get_trafo_values(trafo_df, "tap_dependency_table", na_replacement=float("nan")) tap_dependency_table = np.array( [False if isinstance(x, float) and np.isnan(x) else x for x in tap_dependency_table]) if np.any(np.isnan(tap_dependency_table)): raise UserWarning("tap_dependent_impedance has NaN values, but must be of type " "bool and set to True or False") - tap_pos = get_trafo_values(trafo_df, "tap_pos") + tap_pos = get_trafo_values(trafo_df, "tap_pos", na_replacement=float("nan")) vals = () @@ -952,7 +954,7 @@ def _calc_r_x_from_dataframe(mode, trafo_df, vn_lv, vn_trafo_lv, sn_mva, sequenc """ parallel = get_trafo_values(trafo_df, "parallel") if sequence == 1: - tap_dependency = get_trafo_values(trafo_df, "tap_dependency_table") + tap_dependency = get_trafo_values(trafo_df, "tap_dependency_table", na_replacement=float("nan")) if tap_dependency is not None: tap_dependency = np.array( [False if isinstance(x, float) and np.isnan(x) else x for x in tap_dependency]) @@ -1454,46 +1456,46 @@ def _trafo_df_from_trafo3w(net: pandapowerNet, sequence: int = 1) -> dict: trafo2: dict[str, dict] = {} sides = ["hv", "mv", "lv"] mode = net._options["mode"] - t3 = net["trafo3w"] # todo check magnetizing impedance implementation: # loss_side = net._options["trafo3w_losses"].lower() - loss_side = t3.loss_side.values if "loss_side" in t3.columns else np.full(len(t3), + nr_trafos = len(net.trafo3w) + loss_side = net.trafo3w.loss_side.values if "loss_side" in net.trafo3w.columns else np.full(nr_trafos, net._options["trafo3w_losses"].lower()) - nr_trafos = len(net["trafo3w"]) + if sequence == 1: - if 'tap_dependency_table' in t3: + if 'tap_dependency_table' in net.trafo3w: mode_tmp = "type_c" if mode == "sc" and net._options.get("use_pre_fault_voltage", False) else mode - _calculate_sc_voltages_of_equivalent_transformers(t3, trafo2, mode_tmp, net=net) + _calculate_sc_voltages_of_equivalent_transformers(net.trafo3w, trafo2, mode_tmp, net=net) else: mode_tmp = "type_c" if mode == "sc" and net._options.get("use_pre_fault_voltage", False) else mode - _calculate_sc_voltages_of_equivalent_transformers(t3, trafo2, mode_tmp, characteristic=net.get( + _calculate_sc_voltages_of_equivalent_transformers(net.trafo3w, trafo2, mode_tmp, characteristic=net.get( 'characteristic')) elif sequence == 0: if mode != "sc": raise NotImplementedError( "0 seq impedance calculation only implemented for short-circuit calculation!") - _calculate_sc_voltages_of_equivalent_transformers_zero_sequence(t3, trafo2,) + _calculate_sc_voltages_of_equivalent_transformers_zero_sequence(net.trafo3w, trafo2,) else: raise UserWarning("Unsupported sequence for trafo3w convertion") - _calculate_3w_tap_changers(t3, trafo2, sides) - zeros = np.zeros(len(net.trafo3w)) + _calculate_3w_tap_changers(net, trafo2, sides) + zeros = np.zeros(nr_trafos) aux_buses = net._pd2ppc_lookups["aux"]["trafo3w"] - trafo2["hv_bus"] = {"hv": t3.hv_bus.values, "mv": aux_buses, "lv": aux_buses} - trafo2["lv_bus"] = {"hv": aux_buses, "mv": t3.mv_bus.values, "lv": t3.lv_bus.values} - trafo2["in_service"] = {side: t3.in_service.values for side in sides} + trafo2["hv_bus"] = {"hv": net.trafo3w.hv_bus.values, "mv": aux_buses, "lv": aux_buses} + trafo2["lv_bus"] = {"hv": aux_buses, "mv": net.trafo3w.mv_bus.values, "lv": net.trafo3w.lv_bus.values} + trafo2["in_service"] = {side: net.trafo3w.in_service.values for side in sides} # todo check magnetizing impedance implementation: - # trafo2["i0_percent"] = {side: t3.i0_percent.values if loss_side == side else zeros for side in sides} - # trafo2["pfe_kw"] = {side: t3.pfe_kw.values if loss_side == side else zeros for side in sides} - trafo2["i0_percent"] = {side: np.where(loss_side == side, t3.i0_percent.values, zeros) for side in sides} - trafo2["pfe_kw"] = {side: np.where(loss_side == side, t3.pfe_kw.values, zeros) for side in sides} - trafo2["vn_hv_kv"] = {side: t3.vn_hv_kv.values for side in sides} - trafo2["vn_lv_kv"] = {side: t3["vn_%s_kv" % side].values for side in sides} - trafo2["shift_degree"] = {"hv": np.zeros(nr_trafos), "mv": t3.shift_mv_degree.values, - "lv": t3.shift_lv_degree.values} + # trafo2["i0_percent"] = {side: net.trafo3w.i0_percent.values if loss_side == side else zeros for side in sides} + # trafo2["pfe_kw"] = {side: net.trafo3w.pfe_kw.values if loss_side == side else zeros for side in sides} + trafo2["i0_percent"] = {side: np.where(loss_side == side, net.trafo3w.i0_percent.values, zeros) for side in sides} + trafo2["pfe_kw"] = {side: np.where(loss_side == side, net.trafo3w.pfe_kw.values, zeros) for side in sides} + trafo2["vn_hv_kv"] = {side: net.trafo3w.vn_hv_kv.values for side in sides} + trafo2["vn_lv_kv"] = {side: net.trafo3w["vn_%s_kv" % side].values for side in sides} + trafo2["shift_degree"] = {"hv": np.zeros(nr_trafos), "mv": net.trafo3w.shift_mv_degree.values, + "lv": net.trafo3w.shift_lv_degree.values} for param in ["tap_changer_type", "tap_dependency_table", "id_characteristic_table", "tap_phase_shifter", "tap_at_star_point"]: - if param in t3: - trafo2[param] = {side: t3[param] for side in sides} + if param in net.trafo3w: + trafo2[param] = {side: net.trafo3w[param] for side in sides} trafo2["parallel"] = {side: np.ones(nr_trafos) for side in sides} trafo2["df"] = {side: np.ones(nr_trafos) for side in sides} # even though this is not relevant (at least now), the values cannot be empty: @@ -1591,28 +1593,29 @@ def wye_delta_vector(zbr_n, s): (zbr_n[2, :] + zbr_n[1, :] - zbr_n[0, :])]) -def _calculate_3w_tap_changers(t3, t2, sides): +def _calculate_3w_tap_changers(net, t2, sides): tap_variables = ["tap_side", "tap_pos", "tap_neutral", "tap_max", "tap_min", "tap_step_percent", "tap_step_degree"] - nr_trafos = len(t3) + nr_trafos = len(net.trafo3w) empty = np.zeros(nr_trafos) empty.fill(np.nan) tap_arrays = {var: {side: empty.copy() for side in sides} for var in tap_variables} tap_arrays["tap_side"] = {side: np.array([None] * nr_trafos) for side in sides} - at_star_point = t3.tap_at_star_point.values + at_star_point = net.trafo3w.tap_at_star_point.values any_at_star_point = at_star_point.any() for side in sides: - tap_mask = t3.tap_side.values == side + add_column_to_df(net, 'trafo3w', 'tap_side') + tap_mask = (net.trafo3w.tap_side.fillna("") == side).to_numpy() for var in tap_variables: - if var in t3: - tap_arrays[var][side][tap_mask] = t3[var].values[tap_mask] + if var in net.trafo3w: + tap_arrays[var][side][tap_mask] = net.trafo3w[var].values[tap_mask] else: tap_arrays[var][side][tap_mask] = np.array([float("nan")]*tap_mask.sum()) - # t3 trafos with tap changer at terminals + # net.trafo3w with tap changer at terminals tap_arrays["tap_side"][side][tap_mask] = "hv" if side == "hv" else "lv" - # t3 trafos with tap changer at star points + # net.trafo3w with tap changer at star points if any_at_star_point & np.any(mask_star_point := (tap_mask & at_star_point)): t = (tap_arrays["tap_step_percent"][side][mask_star_point] * np.exp(1j * np.deg2rad(tap_arrays["tap_step_degree"][side][mask_star_point]))) diff --git a/pandapower/build_bus.py b/pandapower/build_bus.py index 95fe7f71f0..b546ac8137 100644 --- a/pandapower/build_bus.py +++ b/pandapower/build_bus.py @@ -339,7 +339,7 @@ def _build_bus_ppc(net, ppc, sequence=None): nr_trafo3w = len(net.trafo3w) nr_ssc = len(net.ssc) nr_vsc = len(net.vsc) - aux = dict() + aux = {} if nr_xward > 0 or nr_trafo3w > 0 or nr_ssc > 0 or nr_vsc > 0: bus_indices = [net["bus"].index.values, np.array([], dtype=np.int64)] max_idx = max(net["bus"].index) + 1 @@ -399,12 +399,11 @@ def _build_bus_ppc(net, ppc, sequence=None): net["xward"]["in_service"].values, net["trafo3w"]["in_service"].values, net["ssc"]["in_service"].values, - net["vsc"]["in_service"].values]) + net["vsc"]["in_service"].values]).astype(bool) else: in_service = net["bus"]["in_service"].values ppc["bus"][~in_service, BUS_TYPE] = NONE - if mode != "nx": - set_reference_buses(net, ppc, bus_lookup, mode) + set_reference_buses(net, ppc, bus_lookup, mode) vm_pu = get_voltage_init_vector(net, init_vm_pu, "magnitude", sequence=sequence) if vm_pu is not None: ppc["bus"][:n_bus, VM] = vm_pu @@ -416,7 +415,7 @@ def _build_bus_ppc(net, ppc, sequence=None): if mode == "sc": _add_c_to_ppc(net, ppc) - if net._options["mode"] == "opf": + if mode == "opf": if "max_vm_pu" in net.bus: ppc["bus"][:n_bus, VMAX] = net["bus"].max_vm_pu.values else: @@ -455,7 +454,7 @@ def _build_bus_dc_ppc(net, ppc): # get bus indices bus_index = net["bus_dc"].index.values # get in service elements - aux = dict() + aux = {} nr_vsc = len(net.vsc) if nr_vsc > 0 and mode != "dc": max_idx = max(net["bus_dc"].index) + 1 @@ -1011,11 +1010,11 @@ def _add_ext_grid_sc_impedance(net, ppc): c = ppc["bus"][eg_buses_ppc, C_MAX] if case == "max" else ppc["bus"][eg_buses_ppc, C_MIN] else: c = 1.1 - if not "s_sc_%s_mva" % case in eg: + if "s_sc_%s_mva" % case not in eg: raise ValueError(("short circuit apparent power s_sc_%s_mva needs to be specified for " "external grid \n Try: net.ext_grid['s_sc_max_mva'] = 1000") % case) s_sc = eg["s_sc_%s_mva" % case].values/ppc['baseMVA'] - if not "rx_%s" % case in eg: + if "rx_%s" % case not in eg: raise ValueError(("short circuit R/X rate rx_%s needs to be specified for external grid \n" " Try: net.ext_grid['rx_max'] = 0.1") % case) rx = eg["rx_%s" % case].values diff --git a/pandapower/build_gen.py b/pandapower/build_gen.py index b43bd07254..41d3bd0d86 100644 --- a/pandapower/build_gen.py +++ b/pandapower/build_gen.py @@ -1,562 +1,563 @@ -# -*- coding: utf-8 -*- - -# Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics -# and Energy System Technology (IEE), Kassel. All rights reserved. - - -import numpy as np -import pandas as pd - -from pandapower.pf.ppci_variables import bustypes -from pandapower.pypower.bustypes import bustypes_dc -from pandapower.pypower.idx_bus import PV, REF, VA, VM, BUS_TYPE, NONE, VMAX, VMIN, SL_FAC as SL_FAC_BUS -from pandapower.pypower.idx_bus_dc import DC_BUS_TYPE, DC_NONE -from pandapower.pypower.idx_gen import QMIN, QMAX, PMIN, PMAX, GEN_BUS, PG, VG, QG, MBASE, SL_FAC, gen_cols -from pandapower.pypower.idx_brch import F_BUS, T_BUS -from pandapower.auxiliary import _subnetworks, _sum_by_group -from pandapower.pypower.idx_ssc import SSC_BUS, SSC_SET_VM_PU, SSC_CONTROLLABLE -from pandapower.pypower.idx_vsc import VSC_MODE_AC, VSC_BUS, VSC_VALUE_AC, VSC_CONTROLLABLE, VSC_MODE_AC_V, \ - VSC_MODE_AC_SL - -import logging - -logger = logging.getLogger(__name__) - - -def _build_gen_ppc(net, ppc): - """ - Takes the empty ppc network and fills it with the gen values. The gen - datatype will be floated afterwards. - - **INPUT**: - **net** -The pandapower format network - - **ppc** - The PYPOWER format network to fill in values - """ - - mode = net["_options"]["mode"] - distributed_slack = net["_options"]["distributed_slack"] - - _is_elements = net["_is_elements"] - gen_order = dict() - f = 0 - for element in ["ext_grid", "gen"]: - f = add_gen_order(gen_order, element, _is_elements, f) - - if mode == "opf": - if len(net.dcline) > 0: - ppc["dcline"] = net.dcline[["loss_mw", "loss_percent"]].values - for element in ["sgen_controllable", "load_controllable", "storage_controllable"]: - f = add_gen_order(gen_order, element, _is_elements, f) - - f = add_gen_order(gen_order, "xward", _is_elements, f) - - _init_ppc_gen(net, ppc, f) - for element, (f, t) in gen_order.items(): - add_element_to_gen(net, ppc, element, f, t) - net._gen_order = gen_order - - if distributed_slack: - # we add the slack weights of the xward elements to the PQ bus and not the PV bus, - # that is why we to treat the xward as a special case - xward_pq_buses = _get_xward_pq_buses(net, ppc) - gen_mask, xward_mask = _gen_xward_mask(net, ppc) - _normalise_slack_weights(ppc, gen_mask, xward_mask, xward_pq_buses) - - -def add_gen_order(gen_order, element, _is_elements, f): - if element in _is_elements and _is_elements[element].any(): - i = np.sum(_is_elements[element]) - gen_order[element] = (f, f + i) - f += i - return f - - -def _init_ppc_gen(net, ppc, nr_gens): - # initialize generator matrix - ppc["gen"] = np.zeros(shape=(nr_gens, gen_cols), dtype=np.float64) - ppc["gen"][:] = np.array([0, 0, 0, 0, 0, 1., - 1., 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0]) - q_lim_default = net._options["q_lim_default"] - p_lim_default = net._options["p_lim_default"] - ppc["gen"][:, PMAX] = p_lim_default - ppc["gen"][:, PMIN] = -p_lim_default - ppc["gen"][:, QMAX] = q_lim_default - ppc["gen"][:, QMIN] = -q_lim_default - - -def add_element_to_gen(net, ppc, element, f, t): - if element == "ext_grid": - _build_pp_ext_grid(net, ppc, f, t) - elif element == "gen": - _build_pp_gen(net, ppc, f, t) - elif element == "sgen_controllable": - _build_pp_pq_element(net, ppc, "sgen", f, t) - elif element == "load_controllable": - _build_pp_pq_element(net, ppc, "load", f, t, inverted=True) - elif element == "storage_controllable": - _build_pp_pq_element(net, ppc, "storage", f, t, inverted=True) - elif element == "xward": - _build_pp_xward(net, ppc, f, t) - else: - raise ValueError("Unknown element %s" % element) - - -def _build_pp_ext_grid(net, ppc, f, t): - delta = net._options["delta"] - eg_is = net._is_elements["ext_grid"] - calculate_voltage_angles = net["_options"]["calculate_voltage_angles"] - bus_lookup = net["_pd2ppc_lookups"]["bus"] - # add ext grid / slack data - eg_buses = bus_lookup[net["ext_grid"]["bus"].values[eg_is]] - ppc["gen"][f:t, GEN_BUS] = eg_buses - ppc["gen"][f:t, VG] = net["ext_grid"]["vm_pu"].values[eg_is] - ppc["gen"][f:t, SL_FAC] = net["ext_grid"]["slack_weight"].values[eg_is] - - # set bus values for external grid buses - if ppc.get("sequence", 1) == 1: - if calculate_voltage_angles: - ppc["bus"][eg_buses, VA] = net["ext_grid"]["va_degree"].values[eg_is] - ppc["bus"][eg_buses, VM] = net["ext_grid"]["vm_pu"].values[eg_is] - if net._options["mode"] == "opf": - add_q_constraints(net, "ext_grid", eg_is, ppc, f, t, delta) - add_p_constraints(net, "ext_grid", eg_is, ppc, f, t, delta) - - if "controllable" in net["ext_grid"]: - # if we do and one of them is false, do this only for the ones, where it is false - eg_constrained = net.ext_grid[eg_is][~net.ext_grid.controllable] - if len(eg_constrained): - eg_constrained_bus_ppc = [bus_lookup[egb] for egb in eg_constrained.bus.values] - ppc["bus"][eg_constrained_bus_ppc, VMAX] = net["ext_grid"]["vm_pu"].values[eg_constrained.index] + delta - ppc["bus"][eg_constrained_bus_ppc, VMIN] = net["ext_grid"]["vm_pu"].values[eg_constrained.index] - delta - else: - # if we dont: - ppc["bus"][eg_buses, VMAX] = net["ext_grid"]["vm_pu"].values[eg_is] + delta - ppc["bus"][eg_buses, VMIN] = net["ext_grid"]["vm_pu"].values[eg_is] - delta - else: - ppc["gen"][f:t, QMIN] = 0 - ppc["gen"][f:t, QMAX] = 0 - - -def _check_gen_vm_limits(net, ppc, gen_buses, gen_is): - # check vm_pu limit violation - v_max_bound = ppc["bus"][gen_buses, VMAX] < net["gen"]["vm_pu"].values[gen_is] - if np.any(v_max_bound): - bound_gens = net["gen"].index.values[gen_is][v_max_bound] - logger.warning("gen vm_pu > bus max_vm_pu for gens {}. " - "Setting bus limit for these gens.".format(bound_gens)) - - v_min_bound = net["gen"]["vm_pu"].values[gen_is] < ppc["bus"][gen_buses, VMIN] - if np.any(v_min_bound): - bound_gens = net["gen"].index.values[gen_is][v_min_bound] - logger.warning("gen vm_pu < bus min_vm_pu for gens {}. " - "Setting bus limit for these gens.".format(bound_gens)) - - # check max_vm_pu / min_vm_pu limit violation - if "max_vm_pu" in net["gen"].columns: - v_max_bound = ppc["bus"][gen_buses, VMAX] < net["gen"]["max_vm_pu"].values[gen_is] - if np.any(v_max_bound): - bound_gens = net["gen"].index.values[gen_is][v_max_bound] - logger.warning("gen max_vm_pu > bus max_vm_pu for gens {}. " - "Setting bus limit for these gens.".format(bound_gens)) - # set only vm of gens which do not violate the limits - ppc["bus"][gen_buses[~v_max_bound], VMAX] = net["gen"]["max_vm_pu"].values[gen_is][~v_max_bound] - else: - # set vm of all gens - ppc["bus"][gen_buses, VMAX] = net["gen"]["max_vm_pu"].values[gen_is] - - if "min_vm_pu" in net["gen"].columns: - v_min_bound = net["gen"]["min_vm_pu"].values[gen_is] < ppc["bus"][gen_buses, VMIN] - if np.any(v_min_bound): - bound_gens = net["gen"].index.values[gen_is][v_min_bound] - logger.warning("gen min_vm_pu < bus min_vm_pu for gens {}. " - "Setting bus limit for these gens.".format(bound_gens)) - # set only vm of gens which do not violate the limits - ppc["bus"][gen_buses[~v_max_bound], VMIN] = net["gen"]["min_vm_pu"].values[gen_is][~v_min_bound] - else: - # set vm of all gens - ppc["bus"][gen_buses, VMIN] = net["gen"]["min_vm_pu"].values[gen_is] - return ppc - - -def _enforce_controllable_vm_pu_p_mw(net, ppc, gen_is, f, t): - delta = net["_options"]["delta"] - bus_lookup = net["_pd2ppc_lookups"]["bus"] - controllable = net["gen"]["controllable"].values[gen_is] - not_controllable = ~controllable.astype(bool) - - # if there are some non-controllable gens -> set vm_pu and p_mw fixed - if np.any(not_controllable): - bus = net["gen"]["bus"][gen_is].values[not_controllable] - vm_pu = net["gen"]["vm_pu"][gen_is].values[not_controllable] - p_mw = net["gen"]["p_mw"][gen_is].values[not_controllable] - - not_controllable_buses = bus_lookup[bus] - ppc["bus"][not_controllable_buses, VMAX] = vm_pu + delta - ppc["bus"][not_controllable_buses, VMIN] = vm_pu - delta - - not_controllable_gens = np.arange(f, t)[not_controllable] - ppc["gen"][not_controllable_gens, PMIN] = p_mw - delta - ppc["gen"][not_controllable_gens, PMAX] = p_mw + delta - return ppc - - -def _build_pp_gen(net, ppc, f, t): - delta = net["_options"]["delta"] - gen_is = net._is_elements["gen"] - bus_lookup = net["_pd2ppc_lookups"]["bus"] - mode = net["_options"]["mode"] - - gen_buses = bus_lookup[net["gen"]["bus"].values[gen_is]] - gen_is_vm = net["gen"]["vm_pu"].values[gen_is] - ppc["gen"][f:t, GEN_BUS] = gen_buses - - # enforce gen active power limits - if net._options["enforce_p_lims"]: - min_p = net["gen"]["min_p_mw"] if "min_p_mw" in net["gen"].columns else pd.Series(index=net["gen"].index, - data=np.nan, dtype=float) - max_p = net["gen"]["max_p_mw"] if "max_p_mw" in net["gen"].columns else pd.Series(index=net["gen"].index, - data=np.nan, dtype=float) - - p_used_full = net["gen"]["p_mw"].clip(lower=min_p, upper=max_p) - p_mw = p_used_full.values[gen_is] - - # detect clipping - orig_p = net["gen"]["p_mw"].values[gen_is] - clipped_mask = ~np.isclose(orig_p, p_mw, equal_nan=True) - if np.any(clipped_mask): - # emit debug-level log message notifying user of clipping - logger.debug( - "Active power limits enforced for gen elements at indices %s", - net["gen"].index[gen_is][clipped_mask].tolist(), - ) - else: - p_mw = net["gen"]["p_mw"].values[gen_is] - - ppc["gen"][f:t, PG] = (p_mw * net["gen"]["scaling"].values[gen_is]) - - ppc["gen"][f:t, MBASE] = net["gen"]["sn_mva"].values[gen_is] - ppc["gen"][f:t, SL_FAC] = net["gen"]["slack_weight"].values[gen_is] - ppc["gen"][f:t, VG] = gen_is_vm - - # set bus values for generator buses - ppc["bus"][gen_buses[ppc["bus"][gen_buses, BUS_TYPE] != REF], BUS_TYPE] = PV - if mode != "se": - ppc["bus"][gen_buses, VM] = gen_is_vm - - add_q_constraints(net, "gen", gen_is, ppc, f, t, delta) - add_p_constraints(net, "gen", gen_is, ppc, f, t, delta) # OPF related - if mode == "opf": - # this considers the vm limits for gens - ppc = _check_gen_vm_limits(net, ppc, gen_buses, gen_is) - if "controllable" in net.gen.columns: - ppc = _enforce_controllable_vm_pu_p_mw(net, ppc, gen_is, f, t) - - -def _build_pp_xward(net, ppc, f, t): - delta = net["_options"]["delta"] - q_lim_default = net._options["q_lim_default"] - bus_lookup = net["_pd2ppc_lookups"]["bus"] - aux_buses = net["_pd2ppc_lookups"]["aux"]["xward"] - xw = net["xward"] - xw_is = net["_is_elements"]['xward'] - ppc["gen"][f:t, GEN_BUS] = bus_lookup[aux_buses[xw_is]] - ppc["gen"][f:t, VG] = xw["vm_pu"][xw_is].values - ppc["gen"][f:t, SL_FAC] = net["xward"]["slack_weight"].values[xw_is] - ppc["gen"][f:t, PMIN] = - delta - ppc["gen"][f:t, PMAX] = + delta - ppc["gen"][f:t, QMIN] = -q_lim_default - ppc["gen"][f:t, QMAX] = q_lim_default - - xward_buses = bus_lookup[aux_buses] - ppc["bus"][xward_buses[xw_is], BUS_TYPE] = PV - ppc["bus"][xward_buses[~xw_is], BUS_TYPE] = NONE - ppc["bus"][xward_buses, VM] = net["xward"]["vm_pu"].values - - -def _build_pp_pq_element(net, ppc, element, f, t, inverted=False): - delta = net._options["delta"] - sign = -1 if inverted else 1 - is_element = net._is_elements["%s_controllable" % element] - tab = net[element] - bus_lookup = net["_pd2ppc_lookups"]["bus"] - buses = bus_lookup[tab["bus"].values[is_element]] - - ppc["gen"][f:t, GEN_BUS] = buses - if "sn_mva" in tab: - ppc["gen"][f:t, MBASE] = tab["sn_mva"].values[is_element] - ppc["gen"][f:t, PG] = sign * tab["p_mw"].values[is_element] * tab["scaling"].values[is_element] - ppc["gen"][f:t, QG] = sign * tab["q_mvar"].values[is_element] * tab["scaling"].values[is_element] - - # set bus values for controllable loads - # ppc["bus"][buses, BUS_TYPE] = PQ - add_q_constraints(net, element, is_element, ppc, f, t, delta, inverted) - add_p_constraints(net, element, is_element, ppc, f, t, delta, inverted) - - -def add_q_constraints(net, element, is_element, ppc, f, t, delta, inverted=False): - tab = net[element] - elem_idx = tab.index[is_element] - gen_rows = np.arange(f, t) - - min_max_q_lims = pd.DataFrame(index=elem_idx, columns=["min_q_mvar", "max_q_mvar"], dtype=float) - min_max_q_lims[:] = np.nan - - # add qmin and qmax limit from q_capability_characteristic - capability_curve_condition = ( - "q_capability_characteristic" in net.keys() - and net._options["enforce_q_lims"] - and element in ["gen", "sgen"] - ) - if capability_curve_condition: - curve_q = _calculate_qmin_qmax_from_q_capability_characteristics(net, element) - if curve_q is not None and not curve_q.empty: - min_max_q_lims.update(curve_q[["min_q_mvar", "max_q_mvar"]]) - - # fill NaNs with limits taken from min/max_q_mvar columns (do not overwrite capability-curve values) - if "min_q_mvar" in tab.columns: - min_defaults = tab["min_q_mvar"].reindex(min_max_q_lims.index) - min_max_q_lims["min_q_mvar"] = min_max_q_lims["min_q_mvar"].fillna(min_defaults) - if "max_q_mvar" in tab.columns: - max_defaults = tab["max_q_mvar"].reindex(min_max_q_lims.index) - min_max_q_lims["max_q_mvar"] = min_max_q_lims["max_q_mvar"].fillna(max_defaults) - - qmin = min_max_q_lims["min_q_mvar"].to_numpy() - qmax = min_max_q_lims["max_q_mvar"].to_numpy() - - # keep only non-NaN limits - valid_min = ~np.isnan(qmin) - valid_max = ~np.isnan(qmax) - - # populate limits in ppc structure - if inverted: - qmin_dest_col, qmin_sign, qmin_delta = QMAX, -1.0, +delta - qmax_dest_col, qmax_sign, qmax_delta = QMIN, -1.0, -delta - else: - qmin_dest_col, qmin_sign, qmin_delta = QMIN, +1.0, -delta - qmax_dest_col, qmax_sign, qmax_delta = QMAX, +1.0, +delta - - if valid_min.any(): - ppc["gen"][gen_rows[valid_min], qmin_dest_col] = (qmin_sign * qmin[valid_min] + qmin_delta) - - if valid_max.any(): - ppc["gen"][gen_rows[valid_max], qmax_dest_col] = (qmax_sign * qmax[valid_max] + qmax_delta) - - -def add_p_constraints(net, element, is_element, ppc, f, t, delta, inverted=False): - tab = net[element] - if "min_p_mw" in tab.columns: - if inverted: - ppc["gen"][f:t, PMAX] = - tab["min_p_mw"].values[is_element] + delta - else: - ppc["gen"][f:t, PMIN] = tab["min_p_mw"].values[is_element] - delta - if "max_p_mw" in tab.columns: - if inverted: - ppc["gen"][f:t, PMIN] = - tab["max_p_mw"].values[is_element] - delta - else: - ppc["gen"][f:t, PMAX] = tab["max_p_mw"].values[is_element] + delta - - -def _check_voltage_setpoints_at_same_bus(ppc): - """ - Checks if voltage-controlling elements (generators, SSC, VSC) at the same bus have different setpoints. - - Given the grid data structure, this function verifies if any bus has voltage setpoints from different - controlling elements that are inconsistent with each other. - It raises a UserWarning if such discrepancies are found. - - Parameters: - ----------- - ppc : dict - The grid data structure, that contains grid data arrays - - Raises: - ------- - UserWarning: - If there are buses with voltage controlling elements that have different voltage setpoints. - - Notes: - ------ - The function specifically checks for voltage setpoints discrepancies between: - 1. Generators - 2. Controllable SSCs - 3. VSCs with voltage control mode on the AC side and controllable state - """ - # generator buses: - gen_bus = ppc['gen'][:, GEN_BUS].astype(np.int64) - # generator setpoints: - gen_vm = ppc['gen'][:, VG] - # ssc buses: - ssc_relevant = np.flatnonzero(ppc['ssc'][:, SSC_CONTROLLABLE] == 1) - ssc_bus = ppc['ssc'][ssc_relevant, SSC_BUS].astype(np.int64) - # ssc setpoints: - ssc_vm = ppc['ssc'][ssc_relevant, SSC_SET_VM_PU] - # vsc buses: - vsc_relevant = np.flatnonzero(((ppc['vsc'][:, VSC_MODE_AC] == VSC_MODE_AC_V) | - (ppc['vsc'][:, VSC_MODE_AC] == VSC_MODE_AC_SL)) & - (ppc['vsc'][:, VSC_CONTROLLABLE] == 1)) - vsc_bus = ppc['vsc'][vsc_relevant, VSC_BUS].astype(np.int64) - # vsc setpoints: - vsc_vm = ppc['vsc'][vsc_relevant, VSC_VALUE_AC] - if _different_values_at_one_bus(np.concatenate([gen_bus, ssc_bus, vsc_bus]), - np.concatenate([gen_vm, ssc_vm, vsc_vm])): - raise UserWarning("Voltage controlling elements, i.e. generators, external grids, or DC lines, " - "at the same bus have different setpoints.") - - -def _check_voltage_angles_at_same_bus(net, ppc): - if net._is_elements["ext_grid"].any(): - gen_va = net.ext_grid.va_degree.values[net._is_elements["ext_grid"]] - eg_gens = net._pd2ppc_lookups["ext_grid"][net.ext_grid.index[net._is_elements["ext_grid"]]] - gen_bus = ppc["gen"][eg_gens, GEN_BUS].astype(np.int64) - if _different_values_at_one_bus(gen_bus, gen_va): - raise UserWarning("Ext grids with different voltage angle setpoints connected to the same bus") - - -def _check_for_reference_bus(ppc): - # todo implement VSC also as slack - ref, _, _ = bustypes(ppc["bus"], ppc["gen"], ppc['vsc']) - # throw an error since no reference bus is defined - if len(ref) == 0: - raise UserWarning("No reference bus is available. Either add an ext_grid or a gen with slack=True") - - # todo test this - bus_dc_type = ppc["bus_dc"][:, DC_BUS_TYPE] - bus_dc_relevant = np.flatnonzero(bus_dc_type != DC_NONE) - ref_dc, b2b_dc, _ = bustypes_dc(ppc["bus_dc"]) - # throw an error since no reference bus is defined - if len(bus_dc_relevant) > 0 and len(ref_dc) == 0 and len(b2b_dc) == 0: - raise UserWarning("No reference bus for the dc grid is available. Add a DC reference bus by setting the " - "DC control mode of at least one VSC converter to 'vm_pu'") - - -def _different_values_at_one_bus(buses, values): - """ - checks if there are different values in any of the - - """ - # buses with one or more generators and their index - unique_bus, index_first_bus = np.unique(buses, return_index=True) - - # voltage setpoint lookup with the voltage of the first occurrence of that bus - first_values = -np.ones(buses.max() + 1) - first_values[unique_bus] = values[index_first_bus] - - # generate voltage setpoints where all generators at the same bus - # have the voltage of the first generator at that bus - values_equal = first_values[buses] - - return not np.allclose(values, values_equal) - - -def _gen_xward_mask(net, ppc): - gen_mask = ~np.isin(ppc['gen'][:, GEN_BUS], net["_pd2ppc_lookups"].get("aux", dict()).get("xward", [])) - xward_mask = np.isin(ppc['gen'][:, GEN_BUS], net["_pd2ppc_lookups"].get("aux", dict()).get("xward", [])) - return gen_mask, xward_mask - - -def _get_xward_pq_buses(net, ppc): - # find the PQ and PV buses of the xwards; in build_branch.py the F_BUS is set to the PQ bus and T_BUS is set to - # the auxiliary PV bus - ft = net["_pd2ppc_lookups"].get('branch', dict()).get("xward", []) - if len(ft) > 0: - f, t = ft - xward_pq_buses = ppc['branch'][f:t, F_BUS].real.astype(np.int64) - xward_pv_buses = ppc['branch'][f:t, T_BUS].real.astype(np.int64) - # ignore the xward buses of xward elements that are out of service - xward_pq_buses = np.setdiff1d(xward_pq_buses, xward_pq_buses[ppc['bus'][xward_pv_buses, BUS_TYPE] == NONE]) - return xward_pq_buses - else: - return np.array([], dtype=np.int64) - - -def _normalise_slack_weights(ppc, gen_mask, xward_mask, xward_pq_buses): - """Unitise the slack contribution factors on each island to sum to 1.""" - subnets = _subnetworks(ppc) - gen_buses = ppc['gen'][gen_mask, GEN_BUS].astype(np.int64) - - # it is possible that xward and gen are at the same bus (but not reasonable) - if len(np.intersect1d(gen_buses, xward_pq_buses)): - raise NotImplementedError("Found some of the xward PQ buses with slack weight > 0 that coincide with PV or " - "SL buses. This configuration is not supported.") - - gen_buses = np.r_[gen_buses, xward_pq_buses] - slack_weights_gen = np.r_[ppc['gen'][gen_mask, SL_FAC], ppc['gen'][xward_mask, SL_FAC]].astype(np.float64) - - # only 1 ext_grid (reference bus) supported and all others are considered as PV buses, - # 1 ext_grid is used as slack and others are converted to PV nodes internally; - # calculate dist_slack for all SL and PV nodes that have non-zero slack weight: - buses_with_slack_weights = ppc['gen'][ppc['gen'][:, SL_FAC] != 0, GEN_BUS].astype(np.int64) - if np.sum(ppc['bus'][buses_with_slack_weights, BUS_TYPE] == REF) > 1: - logger.info("Distributed slack calculation is implemented only for one reference type bus, " - "other reference buses will be converted to PV buses internally.") - - for subnet in subnets: - subnet_gen_mask = np.isin(gen_buses, subnet) - sum_slack_weights = np.sum(slack_weights_gen[subnet_gen_mask]) - if np.isclose(sum_slack_weights, 0): - # ppc['gen'][subnet_gen_mask, SL_FAC] = 0 - raise ValueError("Distributed slack contribution factors in " - "island '%s' sum to zero." % str(subnet)) - elif sum_slack_weights < 0: - raise ValueError("Distributed slack contribution factors in island '%s'" % str(subnet) + - " sum to negative value. Please correct the data.") - else: - # ppc['gen'][subnet_gen_mask, SL_FAC] /= sum_slack_weights - slack_weights_gen /= sum_slack_weights - buses, slack_weights_bus, _ = _sum_by_group(gen_buses[subnet_gen_mask], slack_weights_gen[subnet_gen_mask], - slack_weights_gen[subnet_gen_mask]) - ppc['bus'][buses, SL_FAC_BUS] = slack_weights_bus - - # raise NotImplementedError if there are several separate zones for distributed slack: - if not np.isclose(sum(ppc['bus'][:, SL_FAC_BUS]), 1): - raise NotImplementedError("Distributed slack calculation is not implemented for several separate zones at once," - "please calculate the zones separately.") - - -def _calculate_qmin_qmax_from_q_capability_characteristics(net, element): - """ - For gen/sgen elements with reactive_capability_curve == True, compute min_q_mvar / max_q_mvar from the - q_capability_characteristic table at the current p_mw. - """ - if element not in ["gen", "sgen"]: - logger.warning(f"The given element type is not valid for q_min and q_max reactive power capability calculation " - f"of the {element}. Please give gen or sgen as an argument of the function") - return None - - if net[element].empty: - logger.warning(f"No. of {element} elements is zero.") - return None - - # Filter rows with True 'reactive_capability_curve' - element_data = net[element].loc[net[element]['reactive_capability_curve'].fillna(False)] - - if element_data.empty: - logger.warning(f"No {element} elements with reactive_capability_curve == True found.") - return None - - # Extract the relevant data - q_table_ids = element_data['id_q_capability_characteristic'] - p_mw_values = element_data['p_mw'] - - # Retrieve the q_max and q_min characteristic functions as vectorized callables - q_max_funcs = net.q_capability_characteristic.loc[q_table_ids, 'q_max_characteristic'] - q_min_funcs = net.q_capability_characteristic.loc[q_table_ids, 'q_min_characteristic'] - - # Calculate max & min limits from capability curves - calc_q_max = np.vectorize(lambda func, p: func(p))(q_max_funcs, p_mw_values) - calc_q_min = np.vectorize(lambda func, p: func(p))(q_min_funcs, p_mw_values) - - curve_q = pd.DataFrame( - index=element_data.index, - data={"min_q_mvar": calc_q_min, "max_q_mvar": calc_q_max}, - ) - - if curve_q.isna().any().any(): - logger.warning( - f"Some q capability values for {element} evaluated to NaN. " - f"For those elements, default Q limits will still be used." - ) - - return curve_q +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics +# and Energy System Technology (IEE), Kassel. All rights reserved. + + +import numpy as np +import pandas as pd + + +from pandapower.pf.ppci_variables import bustypes +from pandapower.pypower.bustypes import bustypes_dc +from pandapower.pypower.idx_bus import PV, REF, VA, VM, BUS_TYPE, NONE, VMAX, VMIN, SL_FAC as SL_FAC_BUS +from pandapower.pypower.idx_bus_dc import DC_BUS_TYPE, DC_NONE +from pandapower.pypower.idx_gen import QMIN, QMAX, PMIN, PMAX, GEN_BUS, PG, VG, QG, MBASE, SL_FAC, gen_cols +from pandapower.pypower.idx_brch import F_BUS, T_BUS +from pandapower.auxiliary import _subnetworks, _sum_by_group +from pandapower.pypower.idx_ssc import SSC_BUS, SSC_SET_VM_PU, SSC_CONTROLLABLE +from pandapower.pypower.idx_vsc import VSC_MODE_AC, VSC_BUS, VSC_VALUE_AC, VSC_CONTROLLABLE, VSC_MODE_AC_V, \ + VSC_MODE_AC_SL + +import logging + +logger = logging.getLogger(__name__) + + +def _build_gen_ppc(net, ppc): + """ + Takes the empty ppc network and fills it with the gen values. The gen + datatype will be floated afterwards. + + **INPUT**: + **net** -The pandapower format network + + **ppc** - The PYPOWER format network to fill in values + """ + + mode = net["_options"]["mode"] + distributed_slack = net["_options"]["distributed_slack"] + + _is_elements = net["_is_elements"] + gen_order = {} + f = 0 + for element in ["ext_grid", "gen"]: + f = add_gen_order(gen_order, element, _is_elements, f) + + if mode == "opf": + if len(net.dcline) > 0: + ppc["dcline"] = net.dcline[["loss_mw", "loss_percent"]].values + for element in ["sgen_controllable", "load_controllable", "storage_controllable"]: + f = add_gen_order(gen_order, element, _is_elements, f) + + f = add_gen_order(gen_order, "xward", _is_elements, f) + + _init_ppc_gen(net, ppc, f) + for element, (f, t) in gen_order.items(): + add_element_to_gen(net, ppc, element, f, t) + net._gen_order = gen_order + + if distributed_slack: + # we add the slack weights of the xward elements to the PQ bus and not the PV bus, + # that is why we to treat the xward as a special case + xward_pq_buses = _get_xward_pq_buses(net, ppc) + gen_mask, xward_mask = _gen_xward_mask(net, ppc) + _normalise_slack_weights(ppc, gen_mask, xward_mask, xward_pq_buses) + + +def add_gen_order(gen_order, element, _is_elements, f): + if element in _is_elements and _is_elements[element].any(): + i = np.sum(_is_elements[element]) + gen_order[element] = (f, f + i) + f += i + return f + + +def _init_ppc_gen(net, ppc, nr_gens): + # initialize generator matrix + ppc["gen"] = np.zeros(shape=(nr_gens, gen_cols), dtype=np.float64) + ppc["gen"][:] = np.array([0, 0, 0, 0, 0, 1., 1., 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + q_lim_default = net._options["q_lim_default"] + p_lim_default = net._options["p_lim_default"] + ppc["gen"][:, PMAX] = p_lim_default + ppc["gen"][:, PMIN] = -p_lim_default + ppc["gen"][:, QMAX] = q_lim_default + ppc["gen"][:, QMIN] = -q_lim_default + + +def add_element_to_gen(net, ppc, element, f, t): + if element == "ext_grid": + _build_pp_ext_grid(net, ppc, f, t) + elif element == "gen": + _build_pp_gen(net, ppc, f, t) + elif element == "sgen_controllable": + _build_pp_pq_element(net, ppc, "sgen", f, t) + elif element == "load_controllable": + _build_pp_pq_element(net, ppc, "load", f, t, inverted=True) + elif element == "storage_controllable": + _build_pp_pq_element(net, ppc, "storage", f, t, inverted=True) + elif element == "xward": + _build_pp_xward(net, ppc, f, t) + else: + raise ValueError("Unknown element %s" % element) + + +def _build_pp_ext_grid(net, ppc, f, t): + delta = net._options["delta"] + eg_is = net._is_elements["ext_grid"] + calculate_voltage_angles = net["_options"]["calculate_voltage_angles"] + bus_lookup = net["_pd2ppc_lookups"]["bus"] + # add ext grid / slack data + eg_buses = bus_lookup[net["ext_grid"]["bus"].values[eg_is]] + ppc["gen"][f:t, GEN_BUS] = eg_buses + ppc["gen"][f:t, VG] = net["ext_grid"]["vm_pu"].values[eg_is] + ppc["gen"][f:t, SL_FAC] = net["ext_grid"]["slack_weight"].values[eg_is] + + # set bus values for external grid buses + if ppc.get("sequence", 1) == 1: + if calculate_voltage_angles: + ppc["bus"][eg_buses, VA] = net["ext_grid"]["va_degree"].values[eg_is] + ppc["bus"][eg_buses, VM] = net["ext_grid"]["vm_pu"].values[eg_is] + if net._options["mode"] == "opf": + add_q_constraints(net, "ext_grid", eg_is, ppc, f, t, delta) + add_p_constraints(net, "ext_grid", eg_is, ppc, f, t, delta) + + if "controllable" in net["ext_grid"]: + # if we do and one of them is false, do this only for the ones, where it is false + eg_constrained = net.ext_grid[eg_is][~net.ext_grid.controllable] + if len(eg_constrained): + eg_constrained_bus_ppc = [bus_lookup[egb] for egb in eg_constrained.bus.values] + ppc["bus"][eg_constrained_bus_ppc, VMAX] = net["ext_grid"]["vm_pu"].values[eg_constrained.index] + delta + ppc["bus"][eg_constrained_bus_ppc, VMIN] = net["ext_grid"]["vm_pu"].values[eg_constrained.index] - delta + else: + # if we dont: + ppc["bus"][eg_buses, VMAX] = net["ext_grid"]["vm_pu"].values[eg_is] + delta + ppc["bus"][eg_buses, VMIN] = net["ext_grid"]["vm_pu"].values[eg_is] - delta + else: + ppc["gen"][f:t, QMIN] = 0 + ppc["gen"][f:t, QMAX] = 0 + + +def _check_gen_vm_limits(net, ppc, gen_buses, gen_is): + # check vm_pu limit violation + v_max_bound = ppc["bus"][gen_buses, VMAX] < net["gen"]["vm_pu"].values[gen_is] + if np.any(v_max_bound): + bound_gens = net["gen"].index.values[gen_is][v_max_bound] + logger.warning("gen vm_pu > bus max_vm_pu for gens {}. " + "Setting bus limit for these gens.".format(bound_gens)) + + v_min_bound = net["gen"]["vm_pu"].values[gen_is] < ppc["bus"][gen_buses, VMIN] + if np.any(v_min_bound): + bound_gens = net["gen"].index.values[gen_is][v_min_bound] + logger.warning("gen vm_pu < bus min_vm_pu for gens {}. " + "Setting bus limit for these gens.".format(bound_gens)) + + # check max_vm_pu / min_vm_pu limit violation + if "max_vm_pu" in net["gen"].columns: + v_max_bound = ppc["bus"][gen_buses, VMAX] < net["gen"]["max_vm_pu"].values[gen_is] + if np.any(v_max_bound): + bound_gens = net["gen"].index.values[gen_is][v_max_bound] + logger.warning("gen max_vm_pu > bus max_vm_pu for gens {}. " + "Setting bus limit for these gens.".format(bound_gens)) + # set only vm of gens which do not violate the limits + ppc["bus"][gen_buses[~v_max_bound], VMAX] = net["gen"]["max_vm_pu"].values[gen_is][~v_max_bound] + else: + # set vm of all gens + ppc["bus"][gen_buses, VMAX] = net["gen"]["max_vm_pu"].values[gen_is] + + if "min_vm_pu" in net["gen"].columns: + v_min_bound = net["gen"]["min_vm_pu"].values[gen_is] < ppc["bus"][gen_buses, VMIN] + if np.any(v_min_bound): + bound_gens = net["gen"].index.values[gen_is][v_min_bound] + logger.warning("gen min_vm_pu < bus min_vm_pu for gens {}. " + "Setting bus limit for these gens.".format(bound_gens)) + # set only vm of gens which do not violate the limits + ppc["bus"][gen_buses[~v_max_bound], VMIN] = net["gen"]["min_vm_pu"].values[gen_is][~v_min_bound] + else: + # set vm of all gens + ppc["bus"][gen_buses, VMIN] = net["gen"]["min_vm_pu"].values[gen_is] + return ppc + + +def _enforce_controllable_vm_pu_p_mw(net, ppc, gen_is, f, t): + delta = net["_options"]["delta"] + bus_lookup = net["_pd2ppc_lookups"]["bus"] + controllable = net["gen"]["controllable"].values[gen_is] + not_controllable = ~controllable.astype(bool) + + # if there are some non-controllable gens -> set vm_pu and p_mw fixed + if np.any(not_controllable): + bus = net["gen"]["bus"][gen_is].values[not_controllable] + vm_pu = net["gen"]["vm_pu"][gen_is].values[not_controllable] + p_mw = net["gen"]["p_mw"][gen_is].values[not_controllable] + + not_controllable_buses = bus_lookup[bus] + ppc["bus"][not_controllable_buses, VMAX] = vm_pu + delta + ppc["bus"][not_controllable_buses, VMIN] = vm_pu - delta + + not_controllable_gens = np.arange(f, t)[not_controllable] + ppc["gen"][not_controllable_gens, PMIN] = p_mw - delta + ppc["gen"][not_controllable_gens, PMAX] = p_mw + delta + return ppc + + +def _build_pp_gen(net, ppc, f, t): + delta = net["_options"]["delta"] + gen_is = net._is_elements["gen"] + bus_lookup = net["_pd2ppc_lookups"]["bus"] + mode = net["_options"]["mode"] + + gen_buses = bus_lookup[net["gen"]["bus"].values[gen_is]] + gen_is_vm = net["gen"]["vm_pu"].values[gen_is] + ppc["gen"][f:t, GEN_BUS] = gen_buses + + # enforce gen active power limits + if net._options["enforce_p_lims"]: + min_p = net["gen"]["min_p_mw"] if "min_p_mw" in net["gen"].columns else pd.Series(index=net["gen"].index, + data=np.nan, dtype=float) + max_p = net["gen"]["max_p_mw"] if "max_p_mw" in net["gen"].columns else pd.Series(index=net["gen"].index, + data=np.nan, dtype=float) + + p_used_full = net["gen"]["p_mw"].clip(lower=min_p, upper=max_p) + p_mw = p_used_full.values[gen_is] + + # detect clipping + orig_p = net["gen"]["p_mw"].values[gen_is] + clipped_mask = ~np.isclose(orig_p, p_mw, equal_nan=True) + if np.any(clipped_mask): + # emit debug-level log message notifying user of clipping + logger.debug( + "Active power limits enforced for gen elements at indices %s", + net["gen"].index[gen_is][clipped_mask].tolist(), + ) + else: + p_mw = net["gen"]["p_mw"].values[gen_is] + + ppc["gen"][f:t, PG] = (p_mw * net["gen"]["scaling"].values[gen_is]) + + ppc["gen"][f:t, MBASE] = net["gen"]["sn_mva"].values[gen_is] if "sn_mva" in net["gen"].columns else np.empty(len(gen_buses)) # TODO: sn_mva should not be required + ppc["gen"][f:t, SL_FAC] = net["gen"]["slack_weight"].values[gen_is] if "slack_weight" in net["gen"].columns else np.empty(len(gen_buses))# TODO: slack_weight should not be required + ppc["gen"][f:t, VG] = gen_is_vm + + # set bus values for generator buses + ppc["bus"][gen_buses[ppc["bus"][gen_buses, BUS_TYPE] != REF], BUS_TYPE] = PV + if mode != "se": + ppc["bus"][gen_buses, VM] = gen_is_vm + + add_q_constraints(net, "gen", gen_is, ppc, f, t, delta) + add_p_constraints(net, "gen", gen_is, ppc, f, t, delta) # OPF related + if mode == "opf": + # this considers the vm limits for gens + ppc = _check_gen_vm_limits(net, ppc, gen_buses, gen_is) + if "controllable" in net.gen.columns: + ppc = _enforce_controllable_vm_pu_p_mw(net, ppc, gen_is, f, t) + + +def _build_pp_xward(net, ppc, f, t): + delta = net["_options"]["delta"] + q_lim_default = net._options["q_lim_default"] + bus_lookup = net["_pd2ppc_lookups"]["bus"] + aux_buses = net["_pd2ppc_lookups"]["aux"]["xward"] + xw = net["xward"] + xw_is = net["_is_elements"]['xward'] + ppc["gen"][f:t, GEN_BUS] = bus_lookup[aux_buses[xw_is]] + ppc["gen"][f:t, VG] = xw["vm_pu"][xw_is].values + ppc["gen"][f:t, SL_FAC] = net["xward"]["slack_weight"].values[xw_is] + ppc["gen"][f:t, PMIN] = - delta + ppc["gen"][f:t, PMAX] = + delta + ppc["gen"][f:t, QMIN] = -q_lim_default + ppc["gen"][f:t, QMAX] = q_lim_default + + xward_buses = bus_lookup[aux_buses] + ppc["bus"][xward_buses[xw_is], BUS_TYPE] = PV + ppc["bus"][xward_buses[~xw_is], BUS_TYPE] = NONE + ppc["bus"][xward_buses, VM] = net["xward"]["vm_pu"].values + + +def _build_pp_pq_element(net, ppc, element, f, t, inverted=False): + delta = net._options["delta"] + sign = -1 if inverted else 1 + is_element = net._is_elements["%s_controllable" % element] + tab = net[element] + bus_lookup = net["_pd2ppc_lookups"]["bus"] + buses = bus_lookup[tab["bus"].values[is_element]] + + ppc["gen"][f:t, GEN_BUS] = buses + if "sn_mva" in tab: + ppc["gen"][f:t, MBASE] = tab["sn_mva"].values[is_element] + ppc["gen"][f:t, PG] = sign * tab["p_mw"].values[is_element] * tab["scaling"].values[is_element] + ppc["gen"][f:t, QG] = sign * tab["q_mvar"].values[is_element] * tab["scaling"].values[is_element] + + # set bus values for controllable loads + # ppc["bus"][buses, BUS_TYPE] = PQ + add_q_constraints(net, element, is_element, ppc, f, t, delta, inverted) + add_p_constraints(net, element, is_element, ppc, f, t, delta, inverted) + + +def add_q_constraints(net, element, is_element, ppc, f, t, delta, inverted=False): + tab = net[element] + elem_idx = tab.index[is_element] + gen_rows = np.arange(f, t) + + min_max_q_lims = pd.DataFrame(index=elem_idx, columns=["min_q_mvar", "max_q_mvar"], dtype=float) + min_max_q_lims[:] = np.nan + + # add qmin and qmax limit from q_capability_characteristic + capability_curve_condition = ( + "q_capability_characteristic" in net.keys() + and net._options["enforce_q_lims"] + and element in ["gen", "sgen"] + ) + if capability_curve_condition: + curve_q = _calculate_qmin_qmax_from_q_capability_characteristics(net, element) + if curve_q is not None and not curve_q.empty: + min_max_q_lims.update(curve_q[["min_q_mvar", "max_q_mvar"]]) + + # fill NaNs with limits taken from min/max_q_mvar columns (do not overwrite capability-curve values) + if "min_q_mvar" in tab.columns: + min_defaults = tab["min_q_mvar"].reindex(min_max_q_lims.index) + min_max_q_lims["min_q_mvar"] = min_max_q_lims["min_q_mvar"].fillna(min_defaults) + if "max_q_mvar" in tab.columns: + max_defaults = tab["max_q_mvar"].reindex(min_max_q_lims.index) + min_max_q_lims["max_q_mvar"] = min_max_q_lims["max_q_mvar"].fillna(max_defaults) + + qmin = min_max_q_lims["min_q_mvar"].to_numpy() + qmax = min_max_q_lims["max_q_mvar"].to_numpy() + + # keep only non-NaN limits + valid_min = ~np.isnan(qmin) + valid_max = ~np.isnan(qmax) + + # populate limits in ppc structure + if inverted: + qmin_dest_col, qmin_sign, qmin_delta = QMAX, -1.0, +delta + qmax_dest_col, qmax_sign, qmax_delta = QMIN, -1.0, -delta + else: + qmin_dest_col, qmin_sign, qmin_delta = QMIN, +1.0, -delta + qmax_dest_col, qmax_sign, qmax_delta = QMAX, +1.0, +delta + + if valid_min.any(): + ppc["gen"][gen_rows[valid_min], qmin_dest_col] = (qmin_sign * qmin[valid_min] + qmin_delta) + + if valid_max.any(): + ppc["gen"][gen_rows[valid_max], qmax_dest_col] = (qmax_sign * qmax[valid_max] + qmax_delta) + + +def add_p_constraints(net, element, is_element, ppc, f, t, delta, inverted=False): + tab = net[element] + if "min_p_mw" in tab.columns: + if inverted: + ppc["gen"][f:t, PMAX] = - tab["min_p_mw"].values[is_element] + delta + else: + ppc["gen"][f:t, PMIN] = tab["min_p_mw"].values[is_element] - delta + if "max_p_mw" in tab.columns: + if inverted: + ppc["gen"][f:t, PMIN] = - tab["max_p_mw"].values[is_element] - delta + else: + ppc["gen"][f:t, PMAX] = tab["max_p_mw"].values[is_element] + delta + + +def _check_voltage_setpoints_at_same_bus(ppc): + """ + Checks if voltage-controlling elements (generators, SSC, VSC) at the same bus have different setpoints. + + Given the grid data structure, this function verifies if any bus has voltage setpoints from different + controlling elements that are inconsistent with each other. + It raises a UserWarning if such discrepancies are found. + + Parameters: + ----------- + ppc : dict + The grid data structure, that contains grid data arrays + + Raises: + ------- + UserWarning: + If there are buses with voltage controlling elements that have different voltage setpoints. + + Notes: + ------ + The function specifically checks for voltage setpoints discrepancies between: + 1. Generators + 2. Controllable SSCs + 3. VSCs with voltage control mode on the AC side and controllable state + """ + # generator buses: + gen_bus = ppc['gen'][:, GEN_BUS].astype(np.int64) + # generator setpoints: + gen_vm = ppc['gen'][:, VG] + # ssc buses: + ssc_relevant = np.flatnonzero(ppc['ssc'][:, SSC_CONTROLLABLE] == 1) + ssc_bus = ppc['ssc'][ssc_relevant, SSC_BUS].astype(np.int64) + # ssc setpoints: + ssc_vm = ppc['ssc'][ssc_relevant, SSC_SET_VM_PU] + # vsc buses: + vsc_relevant = np.flatnonzero(((ppc['vsc'][:, VSC_MODE_AC] == VSC_MODE_AC_V) | + (ppc['vsc'][:, VSC_MODE_AC] == VSC_MODE_AC_SL)) & + (ppc['vsc'][:, VSC_CONTROLLABLE] == 1)) + vsc_bus = ppc['vsc'][vsc_relevant, VSC_BUS].astype(np.int64) + # vsc setpoints: + vsc_vm = ppc['vsc'][vsc_relevant, VSC_VALUE_AC] + if _different_values_at_one_bus(np.concatenate([gen_bus, ssc_bus, vsc_bus]), + np.concatenate([gen_vm, ssc_vm, vsc_vm])): + raise UserWarning("Voltage controlling elements, i.e. generators, external grids, or DC lines, " + "at the same bus have different setpoints.") + + +def _check_voltage_angles_at_same_bus(net, ppc): + if net._is_elements["ext_grid"].any(): + gen_va = net.ext_grid.va_degree.values[net._is_elements["ext_grid"]] + eg_gens = net._pd2ppc_lookups["ext_grid"][net.ext_grid.index[net._is_elements["ext_grid"]]] + gen_bus = ppc["gen"][eg_gens, GEN_BUS].astype(np.int64) + if _different_values_at_one_bus(gen_bus, gen_va): + raise UserWarning("Ext grids with different voltage angle setpoints connected to the same bus") + + +def _check_for_reference_bus(ppc): + # todo implement VSC also as slack + ref, _, _ = bustypes(ppc["bus"], ppc["gen"], ppc['vsc']) + # throw an error since no reference bus is defined + if len(ref) == 0: + raise UserWarning("No reference bus is available. Either add an ext_grid or a gen with slack=True") + + # todo test this + bus_dc_type = ppc["bus_dc"][:, DC_BUS_TYPE] + bus_dc_relevant = np.flatnonzero(bus_dc_type != DC_NONE) + ref_dc, b2b_dc, _ = bustypes_dc(ppc["bus_dc"]) + # throw an error since no reference bus is defined + if len(bus_dc_relevant) > 0 and len(ref_dc) == 0 and len(b2b_dc) == 0: + raise UserWarning("No reference bus for the dc grid is available. Add a DC reference bus by setting the " + "DC control mode of at least one VSC converter to 'vm_pu'") + + +def _different_values_at_one_bus(buses, values): + """ + checks if there are different values in any of the + + """ + # buses with one or more generators and their index + unique_bus, index_first_bus = np.unique(buses, return_index=True) + + # voltage setpoint lookup with the voltage of the first occurrence of that bus + first_values = -np.ones(buses.max() + 1) + first_values[unique_bus] = values[index_first_bus] + + # generate voltage setpoints where all generators at the same bus + # have the voltage of the first generator at that bus + values_equal = first_values[buses] + + return not np.allclose(values, values_equal) + + +def _gen_xward_mask(net, ppc): + gen_mask = ~np.isin(ppc['gen'][:, GEN_BUS], net["_pd2ppc_lookups"].get("aux", {}).get("xward", [])) + xward_mask = np.isin(ppc['gen'][:, GEN_BUS], net["_pd2ppc_lookups"].get("aux", {}).get("xward", [])) + return gen_mask, xward_mask + + +def _get_xward_pq_buses(net, ppc): + # find the PQ and PV buses of the xwards; in build_branch.py the F_BUS is set to the PQ bus and T_BUS is set to + # the auxiliary PV bus + ft = net["_pd2ppc_lookups"].get('branch', {}).get("xward", []) + if len(ft) > 0: + f, t = ft + xward_pq_buses = ppc['branch'][f:t, F_BUS].real.astype(np.int64) + xward_pv_buses = ppc['branch'][f:t, T_BUS].real.astype(np.int64) + # ignore the xward buses of xward elements that are out of service + xward_pq_buses = np.setdiff1d(xward_pq_buses, xward_pq_buses[ppc['bus'][xward_pv_buses, BUS_TYPE] == NONE]) + return xward_pq_buses + else: + return np.array([], dtype=np.int64) + + +def _normalise_slack_weights(ppc, gen_mask, xward_mask, xward_pq_buses): + """Unitise the slack contribution factors on each island to sum to 1.""" + subnets = _subnetworks(ppc) + gen_buses = ppc['gen'][gen_mask, GEN_BUS].astype(np.int64) + + # it is possible that xward and gen are at the same bus (but not reasonable) + if len(np.intersect1d(gen_buses, xward_pq_buses)): + raise NotImplementedError("Found some of the xward PQ buses with slack weight > 0 that coincide with PV or " + "SL buses. This configuration is not supported.") + + gen_buses = np.r_[gen_buses, xward_pq_buses] + slack_weights_gen = np.r_[ppc['gen'][gen_mask, SL_FAC], ppc['gen'][xward_mask, SL_FAC]].astype(np.float64) + + # only 1 ext_grid (reference bus) supported and all others are considered as PV buses, + # 1 ext_grid is used as slack and others are converted to PV nodes internally; + # calculate dist_slack for all SL and PV nodes that have non-zero slack weight: + buses_with_slack_weights = ppc['gen'][ppc['gen'][:, SL_FAC] != 0, GEN_BUS].astype(np.int64) + if np.sum(ppc['bus'][buses_with_slack_weights, BUS_TYPE] == REF) > 1: + logger.info("Distributed slack calculation is implemented only for one reference type bus, " + "other reference buses will be converted to PV buses internally.") + + for subnet in subnets: + subnet_gen_mask = np.isin(gen_buses, subnet) + sum_slack_weights = np.sum(slack_weights_gen[subnet_gen_mask]) + if np.isclose(sum_slack_weights, 0): + # ppc['gen'][subnet_gen_mask, SL_FAC] = 0 + raise ValueError("Distributed slack contribution factors in " + "island '%s' sum to zero." % str(subnet)) + elif sum_slack_weights < 0: + raise ValueError("Distributed slack contribution factors in island '%s'" % str(subnet) + + " sum to negative value. Please correct the data.") + else: + # ppc['gen'][subnet_gen_mask, SL_FAC] /= sum_slack_weights + slack_weights_gen /= sum_slack_weights + buses, slack_weights_bus, _ = _sum_by_group(gen_buses[subnet_gen_mask], slack_weights_gen[subnet_gen_mask], + slack_weights_gen[subnet_gen_mask]) + ppc['bus'][buses, SL_FAC_BUS] = slack_weights_bus + + # raise NotImplementedError if there are several separate zones for distributed slack: + if not np.isclose(sum(ppc['bus'][:, SL_FAC_BUS]), 1): + raise NotImplementedError("Distributed slack calculation is not implemented for several separate zones at once," + "please calculate the zones separately.") + + +def _calculate_qmin_qmax_from_q_capability_characteristics(net, element): + """ + For gen/sgen elements with reactive_capability_curve == True, compute min_q_mvar / max_q_mvar from the + q_capability_characteristic table at the current p_mw. + """ + if element not in ["gen", "sgen"]: + logger.warning(f"The given element type is not valid for q_min and q_max reactive power capability calculation " + f"of the {element}. Please give gen or sgen as an argument of the function") + return None + + if net[element].empty: + logger.warning(f"No. of {element} elements is zero.") + return None + + # Filter rows with True 'reactive_capability_curve' + from pandapower.create._utils import add_column_to_df + add_column_to_df(net, element, 'reactive_capability_curve') + element_data = net[element].loc[net[element]['reactive_capability_curve'].fillna(False)] + + if element_data.empty: + logger.warning(f"No {element} elements with reactive_capability_curve == True found.") + return None + + # Extract the relevant data + q_table_ids = element_data['id_q_capability_characteristic'] + p_mw_values = element_data['p_mw'] + + # Retrieve the q_max and q_min characteristic functions as vectorized callables + q_max_funcs = net.q_capability_characteristic.loc[q_table_ids, 'q_max_characteristic'] + q_min_funcs = net.q_capability_characteristic.loc[q_table_ids, 'q_min_characteristic'] + + # Calculate max & min limits from capability curves + calc_q_max = np.vectorize(lambda func, p: func(p))(q_max_funcs, p_mw_values) + calc_q_min = np.vectorize(lambda func, p: func(p))(q_min_funcs, p_mw_values) + + curve_q = pd.DataFrame( + index=element_data.index, + data={"min_q_mvar": calc_q_min, "max_q_mvar": calc_q_max}, + ) + + if curve_q.isna().any().any(): + logger.warning( + f"Some q capability values for {element} evaluated to NaN. " + f"For those elements, default Q limits will still be used." + ) + + return curve_q diff --git a/pandapower/contingency/contingency.py b/pandapower/contingency/contingency.py index e206abca81..5ca9f79d1c 100644 --- a/pandapower/contingency/contingency.py +++ b/pandapower/contingency/contingency.py @@ -8,7 +8,7 @@ import pandas as pd import warnings from packaging.version import Version - +from pandapower.create._utils import add_column_to_df import logging logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ from lightsim2grid.gridmodel.from_pandapower import init as init_ls2g from lightsim2grid.contingencyAnalysis import ContingencyAnalysisCPP - from lightsim2grid_cpp import SolverType + from lightsim2grid_cpp import SolverType, GridModel lightsim2grid_installed = True except ImportError: @@ -35,6 +35,29 @@ KLU_solver_available = False from pandapower.run import runpp +from pandapower.auxiliary import pandapowerNet + + +def pp_to_ls2g(net: pandapowerNet) -> "GridModel": + """ + Try to initialize a lightsim2grid model + + Arguments: + net: The pandapower network to use in lightsim2grid + + Returns: + The initialized lightsim2grid model + + Raises: + Any Exception thrown by lightsim2grid is logged and raised again. + """ + try: + ls2g_model = init_ls2g(net) + return ls2g_model + except Exception as e: + logger.error(f"Failed to create lightsim2grid model for network: {net.name}") + logger.exception(e) + raise e def run_contingency(net, nminus1_cases, pf_options=None, pf_options_nminus1=None, write_to_net=True, @@ -164,6 +187,8 @@ def run_contingency_ls2g(net, nminus1_cases, contingency_evaluation_function=run if not lightsim2grid_installed: raise UserWarning("lightsim2grid package not installed. " "Install lightsim2grid e.g. by running 'pip install lightsim2grid' in command prompt.") + add_column_to_df(net, "gen", "min_q_mvar") + add_column_to_df(net, "gen", 'max_q_mvar') # check for continuous bus index starting with 0: n_bus = len(net.bus) last_bus = net.bus.index[-1] @@ -203,11 +228,11 @@ def run_contingency_ls2g(net, nminus1_cases, contingency_evaluation_function=run "slack bus of pandapower will be used." with warnings.catch_warnings(): warnings.filterwarnings("ignore", msg) - lightsim_grid_model = init_ls2g(net) + lightsim_grid_model = pp_to_ls2g(net) net.gen['slack'] = slack_backup solver_type = SolverType.KLU if KLU_solver_available else SolverType.SparseLU else: - lightsim_grid_model = init_ls2g(net) + lightsim_grid_model = pp_to_ls2g(net) solver_type = SolverType.KLUSingleSlack if KLU_solver_available else SolverType.SparseLUSingleSlack if tps_flag: diff --git a/pandapower/control/basic_controller.py b/pandapower/control/basic_controller.py index db5bff6dec..ac574a72ec 100644 --- a/pandapower/control/basic_controller.py +++ b/pandapower/control/basic_controller.py @@ -160,7 +160,7 @@ def __init__(self, net, name=None, in_service=True, order=0, level=0, index=None drop_same_existing_ctrl=False, initial_run=True, overwrite=False, matching_params=None): super(Controller, self).__init__(net, index) - self.matching_params = dict() if matching_params is None else matching_params + self.matching_params = {} if matching_params is None else matching_params # add oneself to net, creating the ['controller'] DataFrame, if necessary # even though this code is repeated in JSONSerializableClass, it is necessary because of how drop_same_existing_controller works # it is still needed in JSONSerializableClass because it is used for characteristics diff --git a/pandapower/control/controller/DERController/DERBasics.py b/pandapower/control/controller/DERController/DERBasics.py index 4511049aa9..9146d0931f 100644 --- a/pandapower/control/controller/DERController/DERBasics.py +++ b/pandapower/control/controller/DERController/DERBasics.py @@ -131,7 +131,3 @@ def __init__(self, p_points_pu, cosphi_points): def step(self, p_pu): cosphi = cosphi_from_pos(np.interp(p_pu, self.p_points_pu, self.cosphi_pos)) return np.tan(np.arccos(cosphi)) * p_pu - - -if __name__ == "__main__": - pass diff --git a/pandapower/control/controller/DERController/PQVAreas.py b/pandapower/control/controller/DERController/PQVAreas.py index 1e9d5fffa4..51bb880c53 100644 --- a/pandapower/control/controller/DERController/PQVAreas.py +++ b/pandapower/control/controller/DERController/PQVAreas.py @@ -498,7 +498,3 @@ def __init__(self, variant, raise_merge_overlap=True): super().__init__(raise_merge_overlap=raise_merge_overlap) self.pq_area = PQArea4105(variant) self.qv_area = QVArea4105(variant) - - -if __name__ == "__main__": - pass diff --git a/pandapower/control/controller/DERController/QModels.py b/pandapower/control/controller/DERController/QModels.py index 3958bf24b5..e38c7d3ce7 100644 --- a/pandapower/control/controller/DERController/QModels.py +++ b/pandapower/control/controller/DERController/QModels.py @@ -162,7 +162,3 @@ def __init__(self, qv_curve): def step(self, vm_pu, p_pu=None): q_pu = self.qv_curve.step(vm_pu) return q_pu - - -if __name__ == "__main__": - pass diff --git a/pandapower/control/controller/DERController/der_control.py b/pandapower/control/controller/DERController/der_control.py index 7888f5599f..ec1fd933ab 100644 --- a/pandapower/control/controller/DERController/der_control.py +++ b/pandapower/control/controller/DERController/der_control.py @@ -223,7 +223,7 @@ def _saturate_sn_mva_step(self, p_pu, q_pu, vm_pu): (0.95 < vm_pu[to_saturate]) & (vm_pu[to_saturate] < 1.05) & (-0.328684 < q_pu[to_saturate]) & any(q_pu[to_saturate] < 0.328684) ): - logger.warning(f"Such kind of saturation is performed that is not in line with" + logger.warning("Such kind of saturation is performed that is not in line with" " VDE AR N 4110: p reduction within 0.95 < vm < 1.05 and " "0.95 < cosphi.") q_pu[to_saturate] = np.clip(q_pu[to_saturate], -sat_s_pu[to_saturate], @@ -241,7 +241,3 @@ def __str__(self): return (f"DERController({el_id_str}, q_model={self.q_model}, pqv_area={self.pqv_area}, " f"saturate_sn_mva={self.saturate_sn_mva}, q_prio={self.q_prio}, " f"damping_coef={self.damping_coef})") - - -if __name__ == "__main__": - pass diff --git a/pandapower/control/controller/const_control.py b/pandapower/control/controller/const_control.py index 11f96adc1a..473bbc081d 100644 --- a/pandapower/control/controller/const_control.py +++ b/pandapower/control/controller/const_control.py @@ -81,7 +81,7 @@ def set_recycle(self, net): net.controller.at[self.index, 'recycle'] = False return # these variables determine what is re-calculated during a time series run - recycle = dict(trafo=False, gen=False, bus_pq=False) + recycle = {'trafo': False, 'gen': False, 'bus_pq': False} if self.element in ["sgen", "load", "storage"] and self.variable in ["p_mw", "q_mvar", "scaling"]: recycle["bus_pq"] = True diff --git a/pandapower/control/controller/pq_control.py b/pandapower/control/controller/pq_control.py index 44859c8228..a6858702d9 100644 --- a/pandapower/control/controller/pq_control.py +++ b/pandapower/control/controller/pq_control.py @@ -150,7 +150,7 @@ def read_profiles(self, time): if not self.ts_absolute: if self.sn_mva.isnull().any(): - logger.error(f"There are PQ controlled elements with NaN sn_mva values.") + logger.error("There are PQ controlled elements with NaN sn_mva values.") self.p_mw = self.p_mw * self.sn_mva self.q_mvar = self.q_mvar * self.sn_mva diff --git a/pandapower/control/controller/station_control.py b/pandapower/control/controller/station_control.py index 42a5350ba5..3b48e650cd 100644 --- a/pandapower/control/controller/station_control.py +++ b/pandapower/control/controller/station_control.py @@ -6,7 +6,9 @@ from pandapower.control.basic_controller import Controller from pandapower.auxiliary import _detect_read_write_flag, read_from_net, write_to_net from pandapower.control.util.auxiliary import get_min_max_q_mvar_from_characteristics_object +from pandapower.create._utils import add_column_to_df from enum import Enum + logger = logging.getLogger(__name__) @@ -669,27 +671,19 @@ def _normalize_distribution_in_service(self, initial_pf_distribution=None): self.output_values_distribution = np.zeros_like(self.output_values_distribution, dtype=np.float64) def _update_min_max_q_mvar(self, net): - if 'min_q_mvar' in net[self.output_element].columns: - if not np.all(np.isnan(net[self.output_element].loc[self.output_element_index, 'id_q_capability_characteristic'].values)): - qmin, _ = get_min_max_q_mvar_from_characteristics_object(net, self.output_element, self.output_element_index) - self.output_min_q_mvar = np.nan_to_num(qmin, nan=-np.inf) - net[self.output_element].loc[self.output_element_index, 'min_q_mvar'] = self.output_min_q_mvar - else: - self.output_min_q_mvar = np.nan_to_num(net[self.output_element].loc[self.output_element_index, 'min_q_mvar'].values, nan=-np.inf) - net[self.output_element].loc[self.output_element_index, 'min_q_mvar'] = self.output_min_q_mvar - else: - self.output_min_q_mvar = list(np.array([-np.inf]*len(self.output_element_index), dtype=np.float64)) + add_column_to_df(net, self.output_element, 'min_q_mvar') + add_column_to_df(net, self.output_element, 'max_q_mvar') - if 'max_q_mvar' in net[self.output_element].columns: - if not np.all(np.isnan(net[self.output_element].loc[self.output_element_index, 'id_q_capability_characteristic'].values)): - _, qmax = get_min_max_q_mvar_from_characteristics_object(net, self.output_element, self.output_element_index) - self.output_max_q_mvar = np.nan_to_num(qmax, nan=np.inf) - net[self.output_element].loc[self.output_element_index, 'max_q_mvar'] = self.output_max_q_mvar - else: - self.output_max_q_mvar = np.nan_to_num(net[self.output_element].loc[self.output_element_index, 'max_q_mvar'].values, nan=np.inf) - net[self.output_element].loc[self.output_element_index, 'max_q_mvar'] = self.output_max_q_mvar + if ('id_q_capability_characteristic' in net[self.output_element] and not np.all(np.isnan(net[self.output_element].loc[self.output_element_index, 'id_q_capability_characteristic'].values))): + qmin, qmax = get_min_max_q_mvar_from_characteristics_object(net, self.output_element, self.output_element_index) + self.output_min_q_mvar = np.nan_to_num(qmin, nan=-np.inf) + self.output_max_q_mvar = np.nan_to_num(qmax, nan=np.inf) else: - self.output_max_q_mvar = list(np.array([np.inf]*len(self.output_element_index), dtype=np.float64)) + self.output_min_q_mvar = np.nan_to_num(net[self.output_element].loc[self.output_element_index, 'min_q_mvar'].values, nan=-np.inf) + self.output_max_q_mvar = np.nan_to_num(net[self.output_element].loc[self.output_element_index, 'max_q_mvar'].values, nan=np.inf) + + net[self.output_element].loc[self.output_element_index, 'min_q_mvar'] = self.output_min_q_mvar + net[self.output_element].loc[self.output_element_index, 'max_q_mvar'] = self.output_max_q_mvar class DroopControl(Controller): """ diff --git a/pandapower/control/controller/trafo/DiscreteTapControl.py b/pandapower/control/controller/trafo/DiscreteTapControl.py index e6c4a7f41d..e92b5edc92 100644 --- a/pandapower/control/controller/trafo/DiscreteTapControl.py +++ b/pandapower/control/controller/trafo/DiscreteTapControl.py @@ -130,7 +130,6 @@ def is_converged(self, net): is_nan = np.isnan(vm_pu) self.tap_pos = read_from_net( net, self.element, self.element_index, "tap_pos", self._read_write_flag) - reached_limit = np.where(self.tap_side_coeff * self.tap_sign == 1, (vm_pu < self.vm_lower_pu) & (self.tap_pos == self.tap_min) | (vm_pu > self.vm_upper_pu) & (self.tap_pos == self.tap_max), @@ -140,3 +139,56 @@ def is_converged(self, net): converged = np.logical_or(reached_limit, np.logical_and(self.vm_lower_pu < vm_pu, vm_pu < self.vm_upper_pu)) return np.all(np.logical_or(converged, is_nan)) + + def __eq__(self, other): + """ + Checks if two DiscreteTapControl instances are equal. + """ + """ + Checks if two DiscreteTapControl instances are equal by comparing all relevant attributes. + + Args: + other: Another object to compare with + + Returns: + bool: True if controllers are equal, False otherwise + """ + if not isinstance(other, DiscreteTapControl): + return False + + # Compare inherited attributes from TrafoController + if not (self.element_index == other.element_index and + self.side == other.side and + self.element == other.element and + self.tol == other.tol): + return False + + # Compare DiscreteTapControl specific attributes + if not (np.isclose(self.vm_lower_pu, other.vm_lower_pu) and + np.isclose(self.vm_upper_pu, other.vm_upper_pu) and + np.isclose(self.vm_delta_pu, other.vm_delta_pu) and + self.hunting_limit == other.hunting_limit): + return False + + def compare_attribute(self, other, attr_name): + self_value = getattr(self, attr_name, None) + other_value = getattr(other, attr_name, None) + + if self_value is None and other_value is None: + return True + elif self_value is not None and other_value is not None: + return np.isclose(self_value, other_value) + else: + return False + + for ele in ["vm_set_pu", "tap_pos"]: + if not compare_attribute(self, other, ele): + return False + + # Compare hunting_taps arrays + if self._hunting_taps.shape != other._hunting_taps.shape: + return False + if not np.allclose(self._hunting_taps, other._hunting_taps, equal_nan=True): + return False + + return True \ No newline at end of file diff --git a/pandapower/control/controller/trafo_control.py b/pandapower/control/controller/trafo_control.py index 9d8d6e52fd..f16ecda5c1 100644 --- a/pandapower/control/controller/trafo_control.py +++ b/pandapower/control/controller/trafo_control.py @@ -2,7 +2,7 @@ # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. - +import pandas as pd import numpy as np from pandapower.auxiliary import read_from_net, _detect_read_write_flag @@ -102,8 +102,12 @@ def nothing_to_do(self, net): def _set_tap_side_coeff(self, net): tap_side = read_from_net(net, self.element, self.element_index, 'tap_side', self._read_write_flag) - if (len(np.setdiff1d(tap_side, ['hv', 'lv'])) > 0 and self.element == "trafo") or \ - (len(np.setdiff1d(tap_side, ['hv', 'lv', 'mv'])) > 0 and self.element == "trafo3w"): + tap_side_contains_na = pd.isna(tap_side) if pd.api.types.is_scalar(tap_side) else pd.isna(tap_side).any() + if ( + tap_side_contains_na or + (len(np.setdiff1d(tap_side, ['hv', 'lv'])) > 0 and self.element == "trafo") or + (len(np.setdiff1d(tap_side, ['hv', 'lv', 'mv'])) > 0 and self.element == "trafo3w") + ): raise ValueError("Trafo tap side (in net.%s) has to be either hv or lv, " "but received: %s for trafo %s" % (self.element, tap_side, self.element_index)) @@ -111,7 +115,7 @@ def _set_tap_side_coeff(self, net): self.tap_side_coeff = 1 if tap_side == 'hv' else -1 if self.side == "hv": self.tap_side_coeff *= -1 - if self.tap_step_percent < 0: + if pd.notna(self.tap_step_percent) and self.tap_step_percent < 0: self.tap_side_coeff *= -1 else: self.tap_side_coeff = np.where(tap_side == 'hv', 1, -1) @@ -156,18 +160,18 @@ def _set_tap_parameters(self, net): self.tap_pos = read_from_net(net, self.element, self.element_index, "tap_pos", self._read_write_flag) if self._read_write_flag == "single_index": - self.tap_sign = 1 if np.isnan(self.tap_step_degree) else np.sign(np.cos(np.deg2rad(self.tap_step_degree))) + self.tap_sign = 1 if pd.isna(self.tap_step_degree) else np.sign(np.cos(np.deg2rad(self.tap_step_degree))) if (self.tap_sign == 0) | (np.isnan(self.tap_sign)): self.tap_sign = 1 - if np.isnan(self.tap_pos): + if pd.isna(self.tap_pos): self.tap_pos = self.tap_neutral else: - self.tap_sign = np.where(np.isnan(self.tap_step_degree), 1, + self.tap_sign = np.where(pd.isna(self.tap_step_degree), 1, np.sign(np.cos(np.deg2rad(self.tap_step_degree)))) self.tap_sign = np.where((self.tap_sign == 0) | (np.isnan(self.tap_sign)), 1, self.tap_sign) self.tap_pos = np.where(np.isnan(self.tap_pos), self.tap_neutral, self.tap_pos) - if np.any(np.isnan(self.tap_min)) or np.any(np.isnan(self.tap_max)) or np.any(np.isnan(self.tap_step_percent)): + if np.any(pd.isna(self.tap_min)) or np.any(pd.isna(self.tap_max)) or np.any(pd.isna(self.tap_step_percent)): logger.error("Trafo-Controller has been initialized with NaN values, check " "net.trafo.tap_pos etc. if they are set correctly!") @@ -179,16 +183,14 @@ def set_recycle(self, net): net.controller.at[self.index, 'recycle'] = False return # these variables determine what is re-calculated during a time series run - recycle = dict(trafo=True, gen=False, bus_pq=False) + recycle = {'trafo': True, 'gen': False, 'bus_pq': False} net.controller.at[self.index, 'recycle'] = recycle # def timestep(self, net): # self.tap_pos = net[self.element].at[self.element_index, "tap_pos"] def __repr__(self): - s = '%s of %s %s' % (self.__class__.__name__, self.element, self.element_index) - return s + return f'{self.__class__.__name__} of {self.element} {self.element_index}' def __str__(self): - s = '%s of %s %s' % (self.__class__.__name__, self.element, self.element_index) - return s + return f'{self.__class__.__name__} of {self.element} {self.element_index}' diff --git a/pandapower/control/util/auxiliary.py b/pandapower/control/util/auxiliary.py index ed1afacd99..d97e95cf4d 100644 --- a/pandapower/control/util/auxiliary.py +++ b/pandapower/control/util/auxiliary.py @@ -13,6 +13,7 @@ from pandapower.auxiliary import soft_dependency_error, ensure_iterability from pandapower.control.util.characteristic import SplineCharacteristic, Characteristic +from pandapower.create._utils import add_column_to_df try: import matplotlib.pyplot as plt @@ -218,8 +219,9 @@ def create_trafo_characteristic_object(net): del net["trafo_characteristic_spline"] # 2-winding transformers if (net['trafo_characteristic_table'].index.size > 0 and - net['trafo']['id_characteristic_table'].notna().any()): - time_start = time.time() + 'id_characteristic_table' in net.trafo.columns and + net['trafo']['id_characteristic_table'].notna().any() + ): logger.info("Creating tap dependent characteristic objects for 2w-trafos.") characteristic_df_temp = net['trafo_characteristic_table'][ ['id_characteristic', 'step', 'voltage_ratio', 'angle_deg', 'vk_percent', 'vkr_percent']] @@ -232,14 +234,14 @@ def create_trafo_characteristic_object(net): y_points = {col: [characteristic_df[col].tolist()] for col in variables_filtered} _create_trafo_characteristics(net, "trafo", [trafo_id], variables_filtered, x_points, y_points) - logger.info(f"Finished creating tap dependent characteristic objects for 2w-trafos in " - f"{time.time() - time_start}.") + logger.info("Finished creating tap dependent characteristic objects for 2w-trafos.") else: logger.info("trafo_characteristic_table has no values for 2w-trafos - no characteristic objects created.") # 3-winding transformers if (net['trafo_characteristic_table'].index.size > 0 and - net['trafo3w']['id_characteristic_table'].notna().any()): - time_start = time.time() + 'id_characteristic_table' in net.trafo3w.columns and + net['trafo3w']['id_characteristic_table'].notna().any() + ): logger.info("Creating tap dependent characteristic objects for 3w-trafos.") characteristic_df_temp = net['trafo_characteristic_table'][ ['id_characteristic', 'step', 'voltage_ratio', 'angle_deg', 'vk_hv_percent', 'vkr_hv_percent', @@ -254,8 +256,7 @@ def create_trafo_characteristic_object(net): y_points = {col: [characteristic_df[col].tolist()] for col in variables_filtered} _create_trafo_characteristics(net, "trafo3w", [trafo_id], variables_filtered, x_points, y_points) - logger.info(f"Finished creating tap dependent characteristic objects for 3w-trafos in " - f"{time.time() - time_start}.") + logger.info("Finished creating tap dependent characteristic objects for 3w-trafos.") else: logger.info("trafo_characteristic_table has no values for 3w-trafos - no characteristic objects created.") @@ -389,10 +390,15 @@ def _set_reactive_capability_curve_flag(net, element): raise UserWarning(f"The given {element} type is not valid for setting curve dependency table flag. " f"Please give gen or sgen as an argument of the function") # Quick checks for element table and required columns - if (len(net[element]) == 0 or - not {"id_q_capability_characteristic", "reactive_capability_curve", "curve_style"}.issubset(net[element].columns) - or (not net[element]['id_q_capability_characteristic'].notna().any() and - not net[element]['reactive_capability_curve'].any()) and not net[element]['curve_style'].any()): + if ( + len(net[element]) == 0 + or not {"id_q_capability_characteristic", "reactive_capability_curve", "curve_style"}.issubset(net[element].columns) + or ( + net[element]['id_q_capability_characteristic'].isna().all() + and not net[element]['reactive_capability_curve'].any() + ) + and not net[element]['curve_style'].any() + ): logger.info(f"No {element} with Q capability curve table found.") else: net[element]['reactive_capability_curve'] = ( @@ -523,7 +529,8 @@ def get_min_max_q_mvar_from_characteristics_object(net, element, element_index): if np.any(pd.isna(calc_q_min)) or np.any(pd.isna(calc_q_max)): logger.warning(f"The reactive_capability_curve of {element} is True, but the relevant " f"characteristic value is None. So default Q limit value has been used in the load flow.") - + add_column_to_df(net, "sgen", "min_q_mvar") + add_column_to_df(net, "sgen", "max_q_mvar") curve_q = net[element][["min_q_mvar", "max_q_mvar"]] curve_q.loc[element_data.index] = np.column_stack((calc_q_min, calc_q_max)) qmin = curve_q.loc[element_index, "min_q_mvar"] diff --git a/pandapower/control/util/diagnostic.py b/pandapower/control/util/diagnostic.py index c67e8d472a..349a041654 100644 --- a/pandapower/control/util/diagnostic.py +++ b/pandapower/control/util/diagnostic.py @@ -4,6 +4,8 @@ # and Energy System Technology (IEE), Kassel. All rights reserved. from copy import deepcopy +import pandas as pd + from pandapower.control.util.auxiliary import get_controller_index from pandapower.control.controller.trafo_control import TrafoController @@ -128,7 +130,7 @@ def trafo_characteristic_table_diagnostic(net): f"characteristics populated in the trafo_characteristic_table.", category=UserWarning) warnings_count += 1 # check tap_dependency_table & id_characteristic_table column types - if net[trafo_table]['tap_dependency_table'].dtype != 'bool': + if net[trafo_table]['tap_dependency_table'].dtype != pd.BooleanDtype(): warnings.warn(f"The tap_dependency_table column in the {trafo_table} table is not of bool type.", category=UserWarning) warnings_count += 1 @@ -166,13 +168,13 @@ def shunt_characteristic_table_diagnostic(net): (~net["shunt"]['step_dependency_table'] & net["shunt"]['id_characteristic_table'].notna()) ].shape[0] if mismatch != 0: - warnings.warn(f"Found {mismatch} shunt(s) with not both " - f"step_dependency_table and id_characteristic_table parameters populated. " - f"Power flow calculation will raise an error.", category=UserWarning) + warnings.warn( + f"Found {mismatch} shunt(s) with not both step_dependency_table and id_characteristic_table parameters populated. " + f"Power flow calculation will raise an error.", category=UserWarning + ) warnings_count += 1 # check if all relevant columns are populated in the shunt_characteristic_table - temp = net["shunt"].dropna(subset=["id_characteristic_table"])[ - ["step_dependency_table", "id_characteristic_table"]] + temp = net.shunt.dropna(subset=["id_characteristic_table"])[["step_dependency_table", "id_characteristic_table"]] merged_df = temp.merge(net["shunt_characteristic_table"], left_on="id_characteristic_table", right_on="id_characteristic", how="inner") unpopulated = merged_df.loc[~merged_df[cols].notna().all(axis=1)] @@ -181,11 +183,11 @@ def shunt_characteristic_table_diagnostic(net): "populated in the shunt_characteristic_table.", category=UserWarning) warnings_count += 1 # check step_dependency_table & id_characteristic_table column types - if net["shunt"]['step_dependency_table'].dtype != 'bool': + if net.shunt.step_dependency_table.dtype != pd.BooleanDtype(): warnings.warn("The step_dependency_table column in the shunt table is not of bool type.", category=UserWarning) warnings_count += 1 - if net["shunt"]['id_characteristic_table'].dtype != 'Int64': + if net.shunt.id_characteristic_table.dtype != 'Int64': warnings.warn("The id_characteristic_table column in the shunt table is not of Int64 type.", category=UserWarning) warnings_count += 1 diff --git a/pandapower/convert_format.py b/pandapower/convert_format.py index 4a8fcd4ab8..d64f2344ca 100644 --- a/pandapower/convert_format.py +++ b/pandapower/convert_format.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from collections import defaultdict # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. @@ -10,11 +11,12 @@ from packaging.version import Version from pandapower._version import __version__, __format_version__ -from pandapower.create import create_empty_network, create_poly_cost -from pandapower.results import reset_results +from pandapower.auxiliary import pandapowerNet from pandapower.control import TrafoController, BinarySearchControl, DroopControl +from pandapower.create import create_empty_network, create_poly_cost +from pandapower.network_structure import get_structure_dict from pandapower.plotting.geo import convert_geodata_to_geojson, _is_valid_number -from pandapower.auxiliary import pandapowerNet +from pandapower.results import reset_results import logging @@ -31,31 +33,32 @@ def convert_format(net, elements_to_deserialize=None, drop_invalid_geodata=False if Version(str(net.format_version)) > Version(str(net.version).split('.dev')[0]): # TODO: create error/warning when pandapower version is older then network net.format_version = net.version - if isinstance(net.format_version, str) and Version(net.format_version) >= Version(__format_version__): + net_format_version = Version(str(net.format_version)) + if isinstance(net.format_version, str) and net_format_version >= Version(__format_version__): return net _add_nominal_power(net) _add_missing_tables(net) _rename_columns(net, elements_to_deserialize) _add_missing_columns(net, elements_to_deserialize) _create_seperate_cost_tables(net, elements_to_deserialize) - if Version(str(net.format_version)) < Version("3.1.0"): + if net_format_version < Version("3.1.0"): _convert_q_capability_characteristic(net) - if Version("3.0.0") <= Version(str(net.format_version)) < Version("3.1.3"): + if Version("3.0.0") <= net_format_version < Version("3.1.3"): _replace_invalid_data(net, elements_to_deserialize, drop_invalid_geodata) - if Version(str(net.format_version)) < Version("3.0.0"): + if net_format_version < Version("3.0.0"): _convert_geo_data(net, elements_to_deserialize, drop_invalid_geodata) _convert_group_element_index(net) _convert_trafo_controller_parameter_names(net) convert_trafo_pst_logic(net) - if Version(str(net.format_version)) < Version("2.4.0"): + if net_format_version < Version("2.4.0"): _convert_bus_pq_meas_to_load_reference(net, elements_to_deserialize) - if Version(str(net.format_version)) < Version("2.0.0"): + if net_format_version < Version("2.0.0"): _convert_to_generation_system(net, elements_to_deserialize) _convert_costs(net) _convert_to_mw(net) _update_trafo_parameter_names(net, elements_to_deserialize) reset_results(net) - if Version(str(net.format_version)) < Version("1.6"): + if net_format_version < Version("1.6"): set_data_type_of_columns_to_default(net) _convert_objects(net, elements_to_deserialize) _update_characteristics(net, elements_to_deserialize) @@ -67,6 +70,31 @@ def convert_format(net, elements_to_deserialize=None, drop_invalid_geodata=False return net +def _drop_empty_if_not_required(net): + """ + Removes empty columns and DataFrames if not required by network_structure_dict + """ + net_struct = get_structure_dict() + tables_to_drop = [] + exceptions = ["res_bus_sc"] # TODO: why? + for table_name, table in net.items(): + if isinstance(table, pd.DataFrame): + if table_name.startswith("res_"): + if table.empty and f"_empty_{table_name}" not in net_struct and table_name not in exceptions: + tables_to_drop.append(table_name) + continue + if table.empty and table_name not in net_struct: + tables_to_drop.append(table_name) + continue + for col in table.columns: + if table[col].isna().all() and col not in net_struct[table_name]: + del table[col] + + # dual loop required because net.items() should not change during iteration + for table_name in tables_to_drop: + del net[table_name] + + def _convert_q_capability_characteristic(net: pandapowerNet): # rename the q_capability_curve_characteristic table to q_capability_characteristic if exists # this is necessary due to the fact that Excel sheet names have a limit of 31 characters @@ -75,39 +103,36 @@ def _convert_q_capability_characteristic(net: pandapowerNet): def _replace_invalid_data(net, elements_to_deserialize, drop_invalid_geodata): - for element in ['bus', 'bus_dc']: + for element in ['bus', 'bus_dc', 'line', 'line_dc']: if not _check_elements_to_deserialize(element, elements_to_deserialize): continue try: geo_df = net[element]['geo'].dropna().apply(geojson.loads) except TypeError: geo_df = net[element]['geo'].dropna() - for i, geo in geo_df.items(): - coords = geo['coordinates'] - if not drop_invalid_geodata and ((not _is_valid_number(coords[0])) | (not _is_valid_number(coords[1]))): - raise ValueError("There exists invalid bus geodata at index %s. Please clean up your data first or " - "set 'drop_invalid_geodata' to True" % i) - elif (not _is_valid_number(coords[0])) | (not _is_valid_number(coords[1])): - net[element].loc[i, "geo"] = None - logger.warning("bus geodata at index %s is invalid and replaced by None" % i) - - for element in ['line', 'line_dc']: - if not _check_elements_to_deserialize(element, elements_to_deserialize): - continue - try: - geo_df = net[element]['geo'].dropna().apply(geojson.loads) - except TypeError: - geo_df = net[element]['geo'].dropna() - for i, geo in geo_df.items(): - for x, y in geo['coordinates']: - if not drop_invalid_geodata and ((not _is_valid_number(x)) | (not _is_valid_number(y))): - raise ValueError( - "There exists invalid line geodata at index %s. Please clean up your data first or " - "set 'drop_invalid_geodata' to True" % i) - elif (not _is_valid_number(x)) | (not _is_valid_number(y)): - net[element].loc[i, 'geo'] = None - logger.warning("line geodata at index %s is invalid and replaced by None" % i) - break + except KeyError: + geo_df = {} # skip if no geo column exists + if element in ["bus", "bus_dc"]: + for i, geo in geo_df.items(): + coords = geo['coordinates'] + if not drop_invalid_geodata and ((not _is_valid_number(coords[0])) | (not _is_valid_number(coords[1]))): + raise ValueError("There exists invalid bus geodata at index %s. Please clean up your data first or " + "set 'drop_invalid_geodata' to True" % i) + elif (not _is_valid_number(coords[0])) | (not _is_valid_number(coords[1])): + net[element].loc[i, "geo"] = None + logger.warning("bus geodata at index %s is invalid and replaced by None" % i) + + else: + for i, geo in geo_df.items(): + for x, y in geo['coordinates']: + if not drop_invalid_geodata and ((not _is_valid_number(x)) | (not _is_valid_number(y))): + raise ValueError( + "There exists invalid line geodata at index %s. Please clean up your data first or " + "set 'drop_invalid_geodata' to True" % i) + elif (not _is_valid_number(x)) | (not _is_valid_number(y)): + net[element].loc[i, 'geo'] = None + logger.warning("line geodata at index %s is invalid and replaced by None" % i) + break def _convert_geo_data(net, elements_to_deserialize=None, drop_invalid_geodata=True): if ((_check_elements_to_deserialize('bus_geodata', elements_to_deserialize) @@ -139,30 +164,25 @@ def _restore_index_names(net): def correct_dtypes(net, error): """ - Corrects all dtypes of pp element tables if possible. If not and error is True, an Error is - raised. + Corrects all dtypes of pp element tables if possible. If not and error is True, an Error is raised. """ - empty_net = create_empty_network() - not_corrected = [] - failed = {} - for key, table in empty_net.items(): + structure_dict = get_structure_dict(required_only=False) + failed = defaultdict(list) + for key, table in net.items(): if isinstance(table, pd.DataFrame): - if key in net.keys() and isinstance(net[key], pd.DataFrame): - cols = table.columns.intersection(net[key].columns) - diff_cols = cols[~(table.dtypes.loc[cols] == net[key].dtypes.loc[cols])] - for col in diff_cols: + for col in table.columns: + if key not in structure_dict: + # skip unknown/custom dataframes + continue + required_dtype = structure_dict[key].get(col, 'Unknown') + if required_dtype == 'Unknown': + # skip custom columns + continue + if table[col].dtype != required_dtype: try: - net[key][col] = net[key][col].astype(table[col].dtype) + table[col] = table[col].astype(required_dtype) except ValueError: - if key not in failed.keys(): - failed[key] = [col] - else: - failed[key].append(col) - else: - not_corrected.append(key) - if not_corrected: - logger.warning("These keys were not corrected since they miss or are no dataframes: " + str( - not_corrected)) + failed[key].append(col) if failed: msg = "These dtypes could not be corrected: " + str(failed) if error: diff --git a/pandapower/converter/cim/cim2pp/build_pp_net.py b/pandapower/converter/cim/cim2pp/build_pp_net.py index 53ac0d99fe..db180cd2f7 100644 --- a/pandapower/converter/cim/cim2pp/build_pp_net.py +++ b/pandapower/converter/cim/cim2pp/build_pp_net.py @@ -18,6 +18,7 @@ from .. import pp_tools from ..other_classes import ReportContainer, Report, LogLevel, ReportCode from pandapower.control.util.auxiliary import create_q_capability_characteristics_object +from pandapower.network_structure import get_structure_dict logger = logging.getLogger('cim.cim2pp.build_pp_net') @@ -33,7 +34,7 @@ def __init__(self, cim_parser: cim_classes.CimParser, converter_classes: Dict, self.cim_version = cim_version.lower() if cim_version is not None else '2.4.15' self.kwargs = kwargs self.cim: Dict[str, Dict[str, pd.DataFrame]] = self.cim_parser.get_cim_dict() - self.net: pandapowerNet = create_empty_network() + self.net: pandapowerNet = create_empty_network(structure=get_structure_dict(metadata=['cim'])) self.bus_merge: pd.DataFrame = pd.DataFrame() self.power_trafo2w: pd.DataFrame = pd.DataFrame() self.power_trafo3w: pd.DataFrame = pd.DataFrame() diff --git a/pandapower/converter/cim/cim2pp/converter_classes/generators/energySourcesCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/generators/energySourcesCim16.py index 39b92738b2..65701827c8 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/generators/energySourcesCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/generators/energySourcesCim16.py @@ -46,7 +46,7 @@ def _prepare_energy_sources_cim16(self) -> pd.DataFrame: eqssh_energy_sources = pd.merge(eqssh_energy_sources, self.cimConverter.bus_merge, how='left', on='rdfId') eqssh_energy_sources = eqssh_energy_sources.drop_duplicates(['rdfId'], keep='first') sgen_type = {'WP': 'WP', 'Wind': 'WP', 'PV': 'PV', 'SolarPV': 'PV', 'BioGas': 'BioGas', - 'OtherRES': 'OtherRES', 'CHP': 'CHP'} # todo move? + 'OtherRES': 'OtherRES', 'CHP': 'CHP'} # todo move? create mapping file ? eqssh_energy_sources['type'] = eqssh_energy_sources['type'].map(sgen_type) eqssh_energy_sources['p_mw'] = -eqssh_energy_sources['activePower'] eqssh_energy_sources['q_mvar'] = -eqssh_energy_sources['reactivePower'] diff --git a/pandapower/converter/cim/cim2pp/converter_classes/generators/powerElectronicsConnection.py b/pandapower/converter/cim/cim2pp/converter_classes/generators/powerElectronicsConnection.py index ca413b189b..c75b8c2136 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/generators/powerElectronicsConnection.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/generators/powerElectronicsConnection.py @@ -58,6 +58,7 @@ def _prepare_power_electronics_connection(self) -> pd.DataFrame: sort=False) eq_generating_units['type'] = eq_generating_units['type'].fillna('WP') eq_generating_units = eq_generating_units.rename(columns={'rdfId': 'PowerElectronicsUnit'}) + eq_generating_units = eq_generating_units.drop(columns=['name']) eqssh_pecs = self.cimConverter.merge_eq_ssh_profile('PowerElectronicsConnection', add_cim_type_column=True) eqssh_pecs = pd.merge(eqssh_pecs, eq_generating_units, how='left', on='PowerElectronicsUnit') eqssh_pecs = pd.merge(eqssh_pecs, self.cimConverter.bus_merge, how='left', on='rdfId') diff --git a/pandapower/converter/cim/cim2pp/converter_classes/lines/acLineSegmentsCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/lines/acLineSegmentsCim16.py index f8b0fec31a..742d39ee5c 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/lines/acLineSegmentsCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/lines/acLineSegmentsCim16.py @@ -162,8 +162,6 @@ def _prepare_ac_line_segments_cim16(self, convert_line_to_switch, line_r_limit, ac_line_segments['g0_us_per_km'] = abs(ac_line_segments.g0ch) * 1e6 / ac_line_segments.length ac_line_segments['parallel'] = 1 ac_line_segments['df'] = 1. - ac_line_segments['type'] = None - ac_line_segments['std_type'] = None ac_line_segments['et'] = 'b' if self.cimConverter.kwargs.get('set_switch_impedance', False): ac_line_segments['z_ohm'] = (abs(ac_line_segments.r)**2 + abs(ac_line_segments.x)**2)**.5 diff --git a/pandapower/converter/cim/cim2pp/converter_classes/loads/conformLoadsCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/loads/conformLoadsCim16.py index 3b4933f106..5da19542ac 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/loads/conformLoadsCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/loads/conformLoadsCim16.py @@ -39,10 +39,5 @@ def _prepare_conform_loads_cim16(self) -> pd.DataFrame: eqssh_conform_loads['in_service'] = eqssh_conform_loads.connected eqssh_conform_loads = eqssh_conform_loads.rename(columns={'rdfId': sc['o_id'], 'rdfId_Terminal': sc['t'], 'index_bus': 'bus', 'p': 'p_mw', 'q': 'q_mvar'}) - eqssh_conform_loads['const_i_p_percent'] = 0. - eqssh_conform_loads['const_z_p_percent'] = 0. - eqssh_conform_loads['const_i_q_percent'] = 0. - eqssh_conform_loads['const_z_q_percent'] = 0. eqssh_conform_loads['scaling'] = 1. - eqssh_conform_loads['type'] = None return eqssh_conform_loads diff --git a/pandapower/converter/cim/cim2pp/converter_classes/loads/energyConsumersCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/loads/energyConsumersCim16.py index 5554a74304..4598b97332 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/loads/energyConsumersCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/loads/energyConsumersCim16.py @@ -39,10 +39,5 @@ def _prepare_energy_consumers_cim16(self) -> pd.DataFrame: eqssh_energy_consumers['in_service'] = eqssh_energy_consumers.connected eqssh_energy_consumers = eqssh_energy_consumers.rename(columns={'rdfId': sc['o_id'], 'rdfId_Terminal': sc['t'], 'index_bus': 'bus', 'p': 'p_mw', 'q': 'q_mvar'}) - eqssh_energy_consumers['const_i_p_percent'] = 0. - eqssh_energy_consumers['const_z_p_percent'] = 0. - eqssh_energy_consumers['const_i_q_percent'] = 0. - eqssh_energy_consumers['const_z_q_percent'] = 0. eqssh_energy_consumers['scaling'] = 1. - eqssh_energy_consumers['type'] = None return eqssh_energy_consumers diff --git a/pandapower/converter/cim/cim2pp/converter_classes/loads/nonConformLoadsCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/loads/nonConformLoadsCim16.py index 5b219ff56a..2ba687a17c 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/loads/nonConformLoadsCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/loads/nonConformLoadsCim16.py @@ -39,10 +39,5 @@ def _prepare_non_conform_loads_cim16(self) -> pd.DataFrame: eqssh_non_conform_loads['in_service'] = eqssh_non_conform_loads.connected eqssh_non_conform_loads = eqssh_non_conform_loads.rename(columns={ 'rdfId': sc['o_id'], 'rdfId_Terminal': sc['t'], 'index_bus': 'bus', 'p': 'p_mw', 'q': 'q_mvar'}) - eqssh_non_conform_loads['const_i_p_percent'] = 0. - eqssh_non_conform_loads['const_z_p_percent'] = 0. - eqssh_non_conform_loads['const_i_q_percent'] = 0. - eqssh_non_conform_loads['const_z_q_percent'] = 0. eqssh_non_conform_loads['scaling'] = 1. - eqssh_non_conform_loads['type'] = None return eqssh_non_conform_loads \ No newline at end of file diff --git a/pandapower/converter/cim/cim2pp/converter_classes/loads/stationSuppliesCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/loads/stationSuppliesCim16.py index 0cc16ac1bb..1614b949a0 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/loads/stationSuppliesCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/loads/stationSuppliesCim16.py @@ -39,10 +39,5 @@ def _prepare_station_supplies_cim16(self) -> pd.DataFrame: eqssh_station_supplies['in_service'] = eqssh_station_supplies.connected eqssh_station_supplies = eqssh_station_supplies.rename(columns={'rdfId': sc['o_id'], 'rdfId_Terminal': sc['t'], 'index_bus': 'bus', 'p': 'p_mw', 'q': 'q_mvar'}) - eqssh_station_supplies['const_i_p_percent'] = 0. - eqssh_station_supplies['const_z_p_percent'] = 0. - eqssh_station_supplies['const_i_q_percent'] = 0. - eqssh_station_supplies['const_z_q_percent'] = 0. eqssh_station_supplies['scaling'] = 1. - eqssh_station_supplies['type'] = None return eqssh_station_supplies diff --git a/pandapower/converter/cim/cim2pp/converter_classes/switches/switchesCim16.py b/pandapower/converter/cim/cim2pp/converter_classes/switches/switchesCim16.py index d068306010..e9a3ff4bec 100644 --- a/pandapower/converter/cim/cim2pp/converter_classes/switches/switchesCim16.py +++ b/pandapower/converter/cim/cim2pp/converter_classes/switches/switchesCim16.py @@ -80,7 +80,7 @@ def _prepare_switches_cim16(self) -> pd.DataFrame: eqssh_switches = eqssh_switches.rename(columns={'rdfId': sc['o_id'], 'index_bus': 'bus', 'index_bus2': 'element', 'rdfId_Terminal': sc['t_bus'], 'rdfId_Terminal2': sc['t_ele']}) eqssh_switches['et'] = 'b' - eqssh_switches['z_ohm'] = 0 + eqssh_switches['z_ohm'] = 0. if self.cimConverter.cim_version == '3.0' and eqssh_switches.index.size > 0: eqssh_switches['closed'] = (~eqssh_switches.open & eqssh_switches.connected & eqssh_switches.connected2 & eqssh_switches.inService) diff --git a/pandapower/converter/cim/cim_tools.py b/pandapower/converter/cim/cim_tools.py index 29e0272b8d..7bf7d773e6 100644 --- a/pandapower/converter/cim/cim_tools.py +++ b/pandapower/converter/cim/cim_tools.py @@ -6,6 +6,8 @@ import json from typing import Dict, List import numpy as np + +from pandapower.network_structure import get_structure_dict from pandapower.auxiliary import pandapowerNet import pandas as pd @@ -37,111 +39,6 @@ def extend_pp_net_cim(net: pandapowerNet, override: bool = True) -> pandapowerNe only missing columns will be created. Optional, default: True :return: The extended pandapower network. """ - np_str_type = 'str' - np_float_type = 'float' - np_bool_type = 'bool' - - sc = get_pp_net_special_columns_dict() - - # all pandapower element types like bus, line, trafo will get the following special columns - fill_dict_all: Dict[str, List[str]] = {} - fill_dict_all[np_str_type] = [sc['o_id'], sc['o_cl']] - - # special elements - fill_dict: Dict[str, Dict[str, List[str]]] = {} - - fill_dict['bus'] = {} - fill_dict['bus'][np_str_type] = [sc['o_prf'], sc['ct'], sc['cnc_id'], sc['sub_id'], 'description', sc['bb_id'], - sc['bb_name'], 'GeographicalRegion_id', 'GeographicalRegion_name', - 'SubGeographicalRegion_id', 'SubGeographicalRegion_name'] - - fill_dict['ext_grid'] = {} - fill_dict['ext_grid'][np_str_type] = [sc['t'], sc['sub'], 'description', 'RegulatingControl.mode'] - fill_dict['ext_grid'][np_float_type] = ['min_p_mw', 'max_p_mw', 'min_q_mvar', 'max_q_mvar', 'p_mw', 'q_mvar', - 's_sc_max_mva', 's_sc_min_mva', 'rx_max', 'rx_min', 'r0x0_max', 'x0x_max', - 'RegulatingControl.targetValue', 'referencePriority'] - fill_dict['ext_grid'][np_bool_type] = ['RegulatingControl.enabled'] - - fill_dict['load'] = {} - fill_dict['load'][np_str_type] = [sc['t'], 'description'] - fill_dict['gen'] = {} - fill_dict['gen'][np_str_type] = [sc['t'], 'description', 'RegulatingControl.mode'] - fill_dict['gen'][np_float_type] = ['min_p_mw', 'max_p_mw', 'min_q_mvar', 'max_q_mvar', 'vn_kv', 'rdss_ohm', - 'xdss_pu', 'cos_phi', 'pg_percent', 'governorSCD', - 'RegulatingControl.targetValue', 'referencePriority'] - fill_dict['gen'][np_bool_type] = ['RegulatingControl.enabled'] - fill_dict['sgen'] = {} - fill_dict['sgen'][np_str_type] = [sc['t'], 'description', 'generator_type', 'RegulatingControl.mode'] - fill_dict['sgen'][np_float_type] = ['k', 'rx', 'vn_kv', 'rdss_ohm', 'xdss_pu', 'lrc_pu', - 'RegulatingControl.targetValue', 'referencePriority', 'max_p_mw', 'min_p_mw'] - fill_dict['sgen'][np_bool_type] = ['RegulatingControl.enabled'] - fill_dict['motor'] = {} - fill_dict['motor'][np_str_type] = [sc['t'], 'description'] - fill_dict['storage'] = {} - fill_dict['storage'][np_str_type] = [sc['t'], 'description'] - fill_dict['shunt'] = {} - fill_dict['shunt'][np_str_type] = [sc['t'], 'description','sVCControlMode'] - fill_dict['ward'] = {} - fill_dict['ward'][np_str_type] = [sc['t'], 'description'] - fill_dict['xward'] = {} - fill_dict['xward'][np_str_type] = [sc['t'], 'description'] - - fill_dict['line'] = {} - fill_dict['line'][np_str_type] = [sc['t_from'], sc['t_to'], 'description', 'EquipmentContainer_id'] - fill_dict['line'][np_float_type] = ['r0_ohm_per_km', 'x0_ohm_per_km', 'c0_nf_per_km', 'g0_us_per_km', - 'endtemp_degree'] - - fill_dict['dcline'] = {} - fill_dict['dcline'][np_str_type] = [sc['t_from'], sc['t_to'], 'description'] - - fill_dict['switch'] = {} - fill_dict['switch'][np_str_type] = [sc['t_bus'], sc['t_ele'], 'description'] - - fill_dict['impedance'] = {} - fill_dict['impedance'][np_str_type] = [sc['t_from'], sc['t_to'], 'description'] - fill_dict['impedance'][np_float_type] = ['rft0_pu', 'xft0_pu', 'rtf0_pu', 'xtf0_pu'] - - fill_dict['trafo'] = {} - fill_dict['trafo'][np_str_type] = [sc['t_hv'], sc['t_lv'], sc['pte_id_hv'], sc['pte_id_lv'], sc['tc'], sc['tc_id'], - sc['tc2'], sc['tc2_id'], 'tap2_changer_type', 'tap2_side', 'description', - 'vector_group', 'OperationalLimitType.limitType_hv', - 'OperationalLimitType.limitType_lv'] - fill_dict['trafo'][np_float_type] = ['tap2_neutral', 'tap2_min', 'tap2_max', 'tap2_pos', 'tap2_step_percent', - 'tap2_step_degree', 'vk0_percent', 'vkr0_percent', 'xn_ohm', - 'CurrentLimit.value_hv', 'CurrentLimit.value_lv', - 'OperationalLimitType.acceptableDuration_hv', - 'OperationalLimitType.acceptableDuration_lv'] - fill_dict['trafo'][np_bool_type] = ['power_station_unit', 'oltc'] - - fill_dict['trafo3w'] = {} - fill_dict['trafo3w'][np_str_type] = [sc['t_hv'], sc['t_mv'], sc['t_lv'], sc['pte_id_hv'], sc['pte_id_mv'], - sc['pte_id_lv'], sc['tc'], sc['tc_id'], 'description', 'vector_group', - 'OperationalLimitType.limitType_hv', 'OperationalLimitType.limitType_mv', - 'OperationalLimitType.limitType_lv'] - fill_dict['trafo3w'][np_float_type] = ['vk0_hv_percent', 'vk0_mv_percent', 'vk0_lv_percent', 'vkr0_hv_percent', - 'vkr0_mv_percent', 'vkr0_lv_percent', 'CurrentLimit.value_hv', - 'CurrentLimit.value_mv', 'CurrentLimit.value_lv', - 'OperationalLimitType.acceptableDuration_hv', - 'OperationalLimitType.acceptableDuration_mv', - 'OperationalLimitType.acceptableDuration_lv'] - fill_dict['trafo3w'][np_bool_type] = ['power_station_unit'] - - fill_dict['measurement'] = {} - fill_dict['measurement'][np_str_type] = ['source', 'origin_class', 'origin_id', 'analog_id', 'terminal_id', - 'description'] - - for pp_type, one_fd in fill_dict.items(): - for np_type, fields in fill_dict_all.items(): - np_type = np.sctypeDict.get(np_type) - for field in fields: - if override or field not in net[pp_type].columns: - net[pp_type][field] = pd.Series([], dtype=np_type) - for np_type, fields in one_fd.items(): - np_type = np.sctypeDict.get(np_type) - for field in fields: - if override or field not in net[pp_type].columns: - net[pp_type][field] = pd.Series([], dtype=np_type) - # some special items if override: net['CGMES'] = {} diff --git a/pandapower/converter/cim/pp_tools.py b/pandapower/converter/cim/pp_tools.py index ec29029c23..1f568b48a4 100644 --- a/pandapower/converter/cim/pp_tools.py +++ b/pandapower/converter/cim/pp_tools.py @@ -34,7 +34,7 @@ def set_pp_col_types(net: Union[pandapowerNet, Dict], ignore_errors: bool = Fals :return: The pandapower network with updated data types. """ time_start = time.time() - structure_dict = get_structure_dict() + structure_dict = get_structure_dict(required_only=False) pp_elements = ['bus', 'dcline', 'ext_grid', 'gen', 'impedance', 'line', 'load', 'motor', 'sgen', 'shunt', 'storage', 'switch', 'trafo', 'trafo3w', 'ward', 'xward'] columns = ['bus', 'element', 'to_bus', 'from_bus', 'hv_bus', 'mv_bus', 'lv_bus', 'in_service', 'controllable', @@ -53,24 +53,30 @@ def set_pp_col_types(net: Union[pandapowerNet, Dict], ignore_errors: bool = Fals # some individual things if hasattr(net, 'switch'): _set_column_to_type(net['switch'], 'closed', structure_dict['switch']['closed']) - if hasattr(net, 'trafo'): - _set_column_to_type(net['trafo'], 'id_characteristic_table', + if hasattr(net, 'trafo') and 'id_characteristic_table' in net['trafo'].columns: + if 'id_characteristic_table' in net['trafo'].columns: + _set_column_to_type(net['trafo'], 'id_characteristic_table', structure_dict['trafo']['id_characteristic_table']) - if hasattr(net, 'trafo3w'): + if hasattr(net, 'trafo3w') and 'id_characteristic_table' in net['trafo3w'].columns: _set_column_to_type(net['trafo3w'], 'id_characteristic_table', structure_dict['trafo3w']['id_characteristic_table']) if hasattr(net, 'sgen'): - _set_column_to_type(net['sgen'], 'current_source', structure_dict['sgen']['current_source']) - _set_column_to_type(net['sgen'], 'id_q_capability_characteristic', + if 'current_source' in net['sgen'].columns: + _set_column_to_type(net['sgen'], 'current_source', structure_dict['sgen']['current_source']) + if 'id_q_capability_characteristic' in net['sgen'].columns: + _set_column_to_type(net['sgen'], 'id_q_capability_characteristic', structure_dict['sgen']['id_q_capability_characteristic']) if hasattr(net, 'gen'): _set_column_to_type(net['gen'], 'slack', structure_dict['gen']['slack']) - _set_column_to_type(net['gen'], 'id_q_capability_characteristic', + if 'id_q_capability_characteristic' in net['gen'].columns: + _set_column_to_type(net['gen'], 'id_q_capability_characteristic', structure_dict['gen']['id_q_capability_characteristic']) if hasattr(net, 'shunt'): _set_column_to_type(net['shunt'], 'step', structure_dict['shunt']['step']) - _set_column_to_type(net['shunt'], 'max_step', structure_dict['shunt']['max_step']) - _set_column_to_type(net['shunt'], 'id_characteristic_table', + if 'max_step' in net['shunt'].columns: + _set_column_to_type(net['shunt'], 'max_step', structure_dict['shunt']['max_step']) + if 'id_characteristic_table' in net['shunt'].columns: + _set_column_to_type(net['shunt'], 'id_characteristic_table', structure_dict['shunt']['id_characteristic_table']) logger.info("Finished setting the data types for the pandapower network in %ss." % (time.time() - time_start)) return net diff --git a/pandapower/converter/pypower/from_ppc.py b/pandapower/converter/pypower/from_ppc.py index b2a4ed1204..80cae0a039 100644 --- a/pandapower/converter/pypower/from_ppc.py +++ b/pandapower/converter/pypower/from_ppc.py @@ -172,20 +172,22 @@ def _from_ppc_gen(net, ppc): def _from_ppc_branch(net, ppc, f_hz, **kwargs): - """ branch data -> create line, trafo """ + """ + branch data -> create line, trafo + + (g, r_asym, x_asym, g_asym, b_asym): + * for branches that are not transformers but non-zero r_asym, x_asym, g_asym, b_asym + - create as impedance instead of line + * for branches that are transfromers but have non-zero g_asym, b_asym: --> not done yet + - write a new function to convert delta to wye + - obtain the values for rft, rtf, xft, xtf + - calculate ratios for HV portion of r and x + - write the ratios in trafo columns leakage_resistance_ratio_hv, leakage_reactance_ratio_hv + * for branches that are not transformers but connect different voltage levels: + - import them as impedance instead + """ n_bra = ppc["branch"].shape[0] - # todo how to preserve this information (g, r_asym, x_asym, g_asym, b_asym): - # * for branches that are not transformers but non-zero r_asym, x_asym, g_asym, b_asym - # - create as impedance instead of line - # * for branches that are transfromers but have non-zero g_asym, b_asym: --> not done yet - # - write a new function to convert delta to wye - # - obtain the values for rft, rtf, xft, xtf - # - calculate ratios for HV portion of r and x - # - write the ratios in trafo columns leakage_resistance_ratio_hv, leakage_reactance_ratio_hv - # * for branches that are not transformers but connect different voltage levels: - # - import them as impedance instead - zero_column = np.zeros(n_bra, dtype=np.float64) br_r_asym = ppc.get("branch_r_asym", zero_column) br_x_asym = ppc.get("branch_x_asym", zero_column) diff --git a/pandapower/converter/ucte/from_ucte.py b/pandapower/converter/ucte/from_ucte.py index 0688b677a9..9fca34e585 100644 --- a/pandapower/converter/ucte/from_ucte.py +++ b/pandapower/converter/ucte/from_ucte.py @@ -8,11 +8,12 @@ from pandapower.converter.ucte.ucte_converter import UCTE2pandapower from pandapower.converter.ucte.ucte_parser import UCTEParser from pandapower.auxiliary import pandapowerNet +from pandapower.toolbox import get_connected_buses logger = logging.getLogger('ucte.from_ucte') -def from_ucte_dict(ucte_parser: UCTEParser, slack_as_gen: bool = True) -> pandapowerNet: +def from_ucte_dict(ucte_parser: UCTEParser, slack_as_gen: bool = True, clip_small_x_values: bool = True) -> pandapowerNet: """ Creates a pandapower net from an UCTE data structure. @@ -23,17 +24,19 @@ def from_ucte_dict(ucte_parser: UCTEParser, slack_as_gen: bool = True) -> pandap :rtype: pandapowerNet """ - ucte_converter = UCTE2pandapower(slack_as_gen=slack_as_gen) + ucte_converter = UCTE2pandapower(slack_as_gen=slack_as_gen, clip_small_x_values=clip_small_x_values) net = ucte_converter.convert(ucte_parser.get_data()) return net -def from_ucte(ucte_file: str, slack_as_gen: bool = True) -> pandapowerNet: +def from_ucte(ucte_file: str, slack_as_gen: bool = True, clip_small_x_values: bool = False, harmonize_voltage_setpoints: bool = False) -> pandapowerNet: """ Converts net data stored as an UCTE file to a pandapower net. :param str ucte_file: path to the ucte file which includes all the data of the grid (EHV or HV or both) :param bool slack_as_gen: decides whether slack elements are converted as gen or ext_grid elements. + :param bool clip_small_x_values: decides whether small X values shall be clipped to 0.05 Ohm (recommendation from UCTE-DEF) + :param bool harmonize_voltage_setpoints: decides whether voltage setpoints of electrically connected gens shall be harmonized :return: A pandapower net :rtype: pandapowerNet @@ -57,7 +60,10 @@ def from_ucte(ucte_file: str, slack_as_gen: bool = True) -> pandapowerNet: time_start_converting = time.time() - pp_net = from_ucte_dict(ucte_parser, slack_as_gen=slack_as_gen) + pp_net = from_ucte_dict(ucte_parser, slack_as_gen=slack_as_gen, clip_small_x_values=clip_small_x_values) + + if harmonize_voltage_setpoints: + average_voltage_setpoints(pp_net) time_end_converting = time.time() @@ -66,3 +72,79 @@ def from_ucte(ucte_file: str, slack_as_gen: bool = True) -> pandapowerNet: logger.info("Total Time (from_ucte()): %s" % (time_end_converting - time_start_parsing)) return pp_net + +def average_voltage_setpoints(net: pandapowerNet) -> None: + """ + Adjust generator voltage setpoints by averaging vm_pu for generators that + appear to represent the same physical voltage setpoint across connected buses. + + This function mutates net.gen in-place. + + Algorithm / detailed behavior: + - Create a temporary 'prefix' column from the first 7 characters of each + generator name. Generators that share this prefix are treated as a group + of candidates for having a shared voltage setpoint. + - For each group with more than one distinct name: + - Collect the buses where those generators are connected. + - Starting from the first bus in the list, expand the set of buses by + repeatedly calling get_connected_buses(..., consider=('s'), respect_switches=False) + until no new buses are found. This performs a local neighborhood search + across switch connections to find buses electrically tied to the start bus. + - Find which of the group's generator buses (beyond the first) lie within + the expanded connected-bus set. If any are found, consider the first bus + and those matching buses as "critical" for averaging. + - Compute the arithmetic mean of vm_pu for generators connected to the + critical buses and assign that mean to all of those generators' vm_pu. + - Remove the temporary 'prefix' column. + + Parameters + - net: pandapowerNet + A pandapower network object expected to contain a net.gen table with at + least the following columns: + - name (string) + - bus (bus indices) + - vm_pu (voltage setpoint in per-unit) + + Returns + - None + + Side effects and notes + - The function modifies net.gen['vm_pu'] in-place for generators deemed to + share a voltage setpoint. + - A temporary column 'prefix' is added to net.gen and removed before returning. + - Grouping is done by the first 7 characters of the generator name; adjust + this slice if your naming convention differs. + - The function depends on get_connected_buses to discover electrically + connected buses; its behavior (which element types are considered and how + switches are handled) affects which generators are averaged together. + + Example (conceptual) + - If generators "GEN_A_01" and "GEN_A_02" share the same 7-char prefix and + their buses are connected through switches, their vm_pu values will be + replaced by their common average. + """ + net.gen["prefix"] = net.gen["name"].str[:7] + name_sets = ( + net.gen + .groupby("prefix")["name"] + .apply(lambda x: set(x) if len(x) > 1 else None) + .dropna() + .tolist() + ) + for name_set in name_sets: + list_names = list(name_set) + connected_buses = list(net.gen.loc[net.gen.name.isin(list_names), 'bus'].values) + aux_buses = connected_buses[0:1] + len_aux_buses = len(aux_buses) + len_changed = True + while len_changed: + aux_buses += get_connected_buses(net, aux_buses, consider=('s'), respect_switches=False) + if len(aux_buses) > len_aux_buses: + len_aux_buses = len(aux_buses) + else: + len_changed = False + matches = list(set(aux_buses) & set(connected_buses[1:])) + if len(matches): + critical_buses = matches+connected_buses[0:1] + net.gen.loc[net.gen.bus.isin(critical_buses), 'vm_pu'] = net.gen.loc[net.gen.bus.isin(critical_buses), 'vm_pu'].mean() + net.gen = net.gen.drop(columns="prefix") diff --git a/pandapower/converter/ucte/ucte_converter.py b/pandapower/converter/ucte/ucte_converter.py index 6c8b6e3d5c..ad7c236693 100644 --- a/pandapower/converter/ucte/ucte_converter.py +++ b/pandapower/converter/ucte/ucte_converter.py @@ -11,49 +11,28 @@ import numpy as np import pandas as pd +from pandapower.network_structure import get_structure_dict from pandapower.auxiliary import pandapowerNet from pandapower.create import create_empty_network class UCTE2pandapower: - def __init__(self, slack_as_gen: bool = True): + def __init__(self, slack_as_gen: bool = True, clip_small_x_values: bool = True): """ Convert UCTE data to pandapower. """ self.logger = logging.getLogger(self.__class__.__name__) self.u_d: dict = {} - self.net = self._create_empty_network() + self.net = create_empty_network(structure=get_structure_dict(metadata=['ucte'])) self.net.bus["node_name"] = "" self.slack_as_gen = slack_as_gen - - @staticmethod - def _create_empty_network() -> pandapowerNet: - net: pandapowerNet = create_empty_network() - new_columns: dict[str, dict] = { - "trafo": { - "tap2_min": int, - "tap2_max": int, - "tap2_neutral": int, - "tap2_pos": int, - "tap2_step_percent": float, - "tap2_step_degree": float, - "tap2_side": str, - "tap2_changer_type": str, - "amica_name": str, - }, - "line": {"amica_name": str}, - "bus": {"ucte_country": str}, - } - for pp_element in new_columns.keys(): - for col, dtype in new_columns[pp_element].items(): - net[pp_element][col] = pd.Series(dtype=dtype) - return net + self.clip_small_x_values = clip_small_x_values def convert(self, ucte_dict: Dict) -> pandapowerNet: self.logger.info("Converting UCTE data to a pandapower network.") time_start = time.time() # create a temporary copy from the origin input data - self.u_d = dict() + self.u_d = {} for ucte_element, items in ucte_dict.items(): self.u_d[ucte_element] = items.copy() # first reset the index to get indices for pandapower @@ -103,9 +82,6 @@ def convert(self, ucte_dict: Dict) -> pandapowerNet: self._convert_switches() self._convert_trafos() - # copy data to the element tables of self.net - self.net = self.set_pp_col_types(self.net) - # currently, net.bus.name contains the UCTE node name ("Node"), while # net.bus.node_name contains the original node name "Node Name". This is changed now: cols_in_order = list(pd.Series(self.net.bus.columns).replace("node_name", "ucte_name")) @@ -285,7 +261,7 @@ def _convert_lines(self): return # Acceleration # lines = self.u_d['L'] # create the in_service column from the UCTE status - in_service_map = dict({0: True, 1: True, 2: True, 7: False, 8: False, 9: False}) + in_service_map = {0: True, 1: True, 2: True, 7: False, 8: False, 9: False} lines["in_service"] = lines["status"].map(in_service_map) # i in A to i in kA lines["max_i_ka"] = lines["i"] / 1e3 @@ -297,7 +273,15 @@ def _convert_lines(self): lines["length_km"] = 1 self._fill_empty_names(lines) self._fill_amica_names(lines, ":line") - lines.loc[lines.x == 0, "x"] = 0.01 + + if self.clip_small_x_values: + # apply rule of min. X of 0.05 Ohm from UCTE-DEF + lines.loc[(lines.x >= 0.0) & (lines.x < 0.05) , "x"] = +0.05 + lines.loc[(lines.x > -0.05) & (lines.x < 0.0) , "x"] = -0.05 + else: + # being close to the PF approach + lines.loc[lines.x == 0, "x"] = 1e-3 + # rename the columns to the pandapower schema lines = lines.rename( columns={"r": "r_ohm_per_km", "x": "x_ohm_per_km", "name": "name"} @@ -325,8 +309,16 @@ def _convert_impedances(self): trafos_to_impedances = self._get_trafos_modelled_as_impedances() impedances = pd.concat([impedances, trafos_to_impedances]) + if self.clip_small_x_values: + # apply rule of min. X of 0.05 Ohm from UCTE-DEF + impedances.loc[(impedances.x >= 0.0) & (impedances.x < 0.05) , "x"] = +0.05 + impedances.loc[(impedances.x > -0.05) & (impedances.x < 0.0) , "x"] = -0.05 + else: + # being close to the PF approach + impedances.loc[impedances.x == 0, "x"] = 1e-3 + # create the in_service column from the UCTE status - in_service_map = dict({0: True, 1: True, 2: True, 7: False, 8: False, 9: False}) + in_service_map = {0: True, 1: True, 2: True, 7: False, 8: False, 9: False} impedances["in_service"] = impedances["status"].map(in_service_map) # Convert ohm/km to per unit (pu) impedances["sn_mva"] = 10000 # same as PowerFactory @@ -339,6 +331,7 @@ def _convert_impedances(self): impedances["gt_pu"] = impedances["g"] / impedances["z_ohm"] impedances["bf_pu"] = impedances["b"] / impedances["z_ohm"] impedances["bt_pu"] = impedances["b"] / impedances["z_ohm"] + impedances.fillna({'gf_pu': 0.0, 'gt_pu': 0.0, 'bf_pu': 0.0, 'bt_pu': 0.0}, inplace=True) self._fill_empty_names(impedances) self._copy_to_pp("impedance", impedances) self.logger.info("Finished converting the impedances.") @@ -364,30 +357,8 @@ def _get_trafos_modelled_as_impedances(self): trafos_to_impedances = trafos_to_impedances.loc[ trafos_to_impedances.angle_reg_theta.isnull() ] - # calculate iron losses in kW - trafos_to_impedances["pfe_kw"] = ( - trafos_to_impedances.g * trafos_to_impedances.voltage1**2 / 1e3 - ) - # calculate open loop losses in percent of rated current - trafos_to_impedances["i0_percent"] = ( - ( - ( - (trafos_to_impedances.b * 1e-6 * trafos_to_impedances.voltage1**2) - ** 2 - + ( - trafos_to_impedances.g - * 1e-6 - * trafos_to_impedances.voltage1**2 - ) - ** 2 - ) - ** 0.5 - ) - * 100 - / trafos_to_impedances.s - ) trafos_to_impedances = trafos_to_impedances.loc[ - (trafos_to_impedances.pfe_kw == 0) & (trafos_to_impedances.i0_percent == 0) + (trafos_to_impedances.g == 0) & (trafos_to_impedances.b == 0) ] # rename the columns to the pandapower schema, as voltages are the same we can take voltage1 as vn_kv trafos_to_impedances = trafos_to_impedances.rename( @@ -412,7 +383,7 @@ def _convert_switches(self): switches = self.u_d["L"].loc[lines_rxb_zero | switches_by_status, :] # create the in_service column from the UCTE status - in_service_map = dict({0: True, 1: True, 2: True, 7: False, 8: False, 9: False}) + in_service_map = {0: True, 1: True, 2: True, 7: False, 8: False, 9: False} switches["closed"] = switches["status"].map(in_service_map) self._set_column_to_type(switches, "from_bus", int) switches["type"] = "LS" @@ -434,8 +405,17 @@ def _convert_trafos(self): if not len(trafos): self.logger.info("Finished converting the transformers (no transformers existing).") return + + if self.clip_small_x_values: + # apply rule of min. X of 0.05 Ohm from UCTE-DEF + trafos.loc[(trafos.x >= 0.0) & (trafos.x < 0.05) , "x"] = +0.05 + trafos.loc[(trafos.x > -0.05) & (trafos.x < 0.0) , "x"] = -0.05 + else: + # being close to the PF approach + trafos.loc[trafos.x == 0, "x"] = 1e-3 + # create the in_service column from the UCTE status - status_map = dict({0: True, 1: True, 8: False, 9: False}) + status_map = {0: True, 1: True, 8: False, 9: False} trafos["in_service"] = trafos["status"].map(status_map) # use same value as in powerfactory for replacing s equals zero values trafos.loc[trafos.s == 0, "s"] = 1001 @@ -444,7 +424,7 @@ def _convert_trafos(self): # calculate the relative short-circuit voltage trafos["vk_percent"] = ( np.sign(trafos.x) - * (abs(trafos.r) ** 2 + abs(trafos.x) ** 2) ** 0.5 + * (trafos.r ** 2 + trafos.x ** 2) ** 0.5 * (trafos.s * 1e3) / (10.0 * trafos.voltage1**2) ) @@ -465,17 +445,17 @@ def _convert_trafos(self): / trafos.s ) - # phase and angle regulation have to be split up into 5 cases: - # only phase regulated -> pr - # only angle regulated symmetrical model -> ars - # only angle regulated asymmetrical model -> ara - # phase and angle regulated symmetrical model -> pars - # phase and angle regulated asymmetrical model -> para - # set values for only phase regulated transformers (pr) - has_phase_values = ( - (~trafos.phase_reg_delta_u.isnull()) - & (~trafos.phase_reg_n.isnull()) - & (~trafos.phase_reg_n2.isnull()) + trafos = trafos.fillna({'i0_percent': 0.0, 'pfe_kw': 0.0}) + + # phase data in UCTE represent an effect to vm only + # angle data in UCTE represent an effect to va (and maybe vm too) + + # phase and angle regulation have to be split up into several cases + + has_missing_phase_values = ( + trafos.phase_reg_delta_u.isnull() + | trafos.phase_reg_n.isnull() + | trafos.phase_reg_n2.isnull() ) has_missing_angle_values = ( trafos.angle_reg_delta_u.isnull() @@ -483,114 +463,85 @@ def _convert_trafos(self): | trafos.angle_reg_n.isnull() | trafos.angle_reg_n2.isnull() ) - pr = trafos.loc[has_phase_values & has_missing_angle_values].index - - trafos.loc[pr, "tap_min"] = -trafos["phase_reg_n"] - trafos.loc[pr, "tap_max"] = trafos["phase_reg_n"] - trafos.loc[pr, "tap_pos"] = trafos["phase_reg_n2"] - trafos.loc[pr, "tap_step_percent"] = trafos.loc[pr, "phase_reg_delta_u"].abs() - trafos.loc[pr, "tap_changer_type"] = "Ratio" - - # set values for only angle regulated transformers symmetrical and asymmetrical - has_missing_phase_values = ( - trafos.phase_reg_delta_u.isnull() - & trafos.phase_reg_n.isnull() - & trafos.phase_reg_n2.isnull() - ) - has_angle_values = ( - (~trafos.angle_reg_delta_u.isnull()) - & (~trafos.angle_reg_theta.isnull()) - & (~trafos.angle_reg_n.isnull()) - & (~trafos.angle_reg_n2.isnull()) - ) - ar = trafos.loc[has_missing_phase_values & has_angle_values].index - - symm = trafos.angle_reg_type == "SYMM" - ars = trafos.loc[has_missing_phase_values & has_angle_values & symm].index - trafos.loc[ars, "tap_min"] = -trafos.loc[ar, "angle_reg_n"] - trafos.loc[ars, "tap_max"] = trafos.loc[ar, "angle_reg_n"] - trafos.loc[ars, "tap_pos"] = trafos.loc[ar, "angle_reg_n2"] - trafos.loc[ars, "tap_step_percent"] = np.nan - # trafos.loc[ars, 'phase_reg_n'] = trafos.loc[ar, 'angle_reg_n'] - trafos.loc[ars, "tap_changer_type"] = "Ideal" - trafos.loc[ - ars, "tap_step_degree" - ] = self._calculate_tap_step_degree_symmetrical(trafos.loc[ars]) - - asym = (trafos.angle_reg_type == "ASYM") | (trafos.angle_reg_type == "") - ara = trafos.loc[has_missing_phase_values & has_angle_values & asym].index - trafos.loc[ara, "tap2_min"] = -trafos.loc[ar, "angle_reg_n"] - trafos.loc[ara, "tap2_max"] = trafos.loc[ar, "angle_reg_n"] - trafos.loc[ara, "tap2_pos"] = trafos.loc[ar, "angle_reg_n2"] - trafos.loc[ara, "tap2_neutral"] = 0 - trafos.loc[ara, "tap2_step_percent"] = np.nan - trafos.loc[ara, "tap2_changer_type"] = "Ideal" - trafos.loc[ - ara, "tap2_step_degree" - ] = self._calculate_tap_step_degree_asymmetrical(trafos.loc[ara]) - - trafos.loc[ara, "tap_min"] = -trafos.loc[ara, "angle_reg_n"] - trafos.loc[ara, "tap_max"] = trafos.loc[ara, "angle_reg_n"] - trafos.loc[ara, "tap_pos"] = trafos.loc[ara, "angle_reg_n2"] - trafos.loc[ara, "tap_changer_type"] = "Ratio" - trafos.loc[ - ara, "tap_step_percent" - ] = self._calculate_tap_step_percent_asymmetrical(trafos.loc[ara]) - - # get phase and angle regulated transformers symmetrical and asymmetrical - par = trafos.loc[has_phase_values & has_angle_values].index - - trafos.loc[par, "tap_step_percent"] = trafos.loc[par, "phase_reg_delta_u"].abs() - trafos.loc[par, "tap_min"] = -trafos.loc[par, "phase_reg_n"] - trafos.loc[par, "tap_max"] = trafos.loc[par, "phase_reg_n"] - trafos.loc[par, "tap_pos"] = trafos.loc[par, "phase_reg_n2"] - trafos.loc[par, "tap_changer_type"] = "Ratio" - - trafos.loc[par, "tap2_min"] = -trafos.loc[par, "angle_reg_n"] - trafos.loc[par, "tap2_max"] = trafos.loc[par, "angle_reg_n"] - trafos.loc[par, "tap2_neutral"] = 0 - trafos.loc[par, "tap2_pos"] = trafos.loc[par, "angle_reg_n2"] - trafos.loc[par, "tap2_step_percent"] = np.nan - trafos.loc[par, "tap2_changer_type"] = "Ideal" - - pars = trafos.loc[has_phase_values & has_angle_values & symm].index - trafos.loc[ - pars, "tap2_step_degree" - ] = self._calculate_tap_step_degree_symmetrical(trafos.loc[pars]) - - para = trafos.loc[has_phase_values & has_angle_values & asym].index - trafos.loc[ - para, "tap2_step_degree" - ] = self._calculate_tap_step_degree_asymmetrical(trafos.loc[para]) - trafos.loc[para, "tap_step_percent"] = trafos.loc[ - para, "tap_step_percent" - ] + self._calculate_tap_step_percent_asymmetrical(trafos.loc[para]) - - # change signs of tap pos for negative degree or percentage values, since pp only allows positive values - # trafos.loc[trafos.tap_step_percent < 0, ['tap_pos', 'tap_step_percent']] = trafos.loc[trafos.tap_step_percent < 0, ['tap_pos', 'tap_step_percent']] * -1 - # trafos.loc[trafos.tap_step_degree < 0, ['tap_pos', 'tap_step_degree']] = trafos.loc[trafos.tap_step_degree < 0, ['tap_pos', 'tap_step_degree']] * -1 - # trafos.loc[trafos.tap2_step_degree < 0, ['tap2_pos', 'tap2_step_degree']] = trafos.loc[trafos.tap2_step_degree < 0, ['tap2_pos', 'tap2_step_degree']] * -1 + has_phase_values = ~has_missing_phase_values + has_angle_values = ~has_missing_angle_values + has_2nd_tap_changer = has_phase_values & has_angle_values + + symm = trafos.angle_reg_type == "SYMM" # data for symm transformers is included in the angle values + asym = trafos.angle_reg_type == "ASYM" # data for asym transformers is included in the angle values + # but there might be a second tap changer + generic = ~(symm | asym) | has_2nd_tap_changer # data for generic transformers is included in the phase values + + trafos["tap_changer_type"] = "Ratio" + + trafos.loc[generic, "tap_min"] = -trafos["phase_reg_n"] + trafos.loc[generic, "tap_max"] = trafos["phase_reg_n"] + trafos.loc[generic, "tap_pos"] = trafos["phase_reg_n2"] + trafos.loc[generic, "tap_step_percent"] = trafos.loc[generic, "phase_reg_delta_u"] + + idx = trafos.loc[asym & ~(has_2nd_tap_changer)].index + trafos.loc[idx, "tap_min"] = -trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap_max"] = trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap_pos"] = trafos.loc[idx, "angle_reg_n2"] + trafos.loc[idx, "tap_step_percent"] = trafos.loc[idx, "angle_reg_delta_u"] + trafos.loc[idx, "tap_step_degree"] = trafos.loc[idx, "angle_reg_theta"] + + idx = trafos.loc[asym & has_2nd_tap_changer].index + trafos.loc[idx, "tap2_changer_type"] = "Ratio" + trafos.loc[idx, "tap2_min"] = -trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap2_max"] = trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap2_pos"] = trafos.loc[idx, "angle_reg_n2"] + trafos.loc[idx, "tap2_step_percent"] = trafos.loc[idx, "angle_reg_delta_u"] + trafos.loc[idx, "tap2_step_degree"] = trafos.loc[idx, "angle_reg_theta"] + + idx = trafos.loc[symm & ~(has_2nd_tap_changer)].index + trafos.loc[idx, "tap_changer_type"] = "Symmetrical" + trafos.loc[idx, "tap_min"] = -trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap_max"] = trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap_pos"] = trafos.loc[idx, "angle_reg_n2"] + trafos.loc[idx, "tap_step_percent"] = trafos.loc[idx, "angle_reg_delta_u"] + trafos.loc[idx, "tap_step_degree"] = trafos.loc[idx, "angle_reg_theta"] # has to be 90.0° + + idx = trafos.loc[symm & has_2nd_tap_changer].index + # ToDo: in PF, a 2nd tap changer cannot be symmetrical; thus, it is inspected as asymetrical + # trafos.loc[idx, "tap2_changer_type"] = "Symmetrical" + trafos.loc[idx, "tap2_changer_type"] = "Ratio" + trafos.loc[idx, "tap2_min"] = -trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap2_max"] = trafos.loc[idx, "angle_reg_n"] + trafos.loc[idx, "tap2_pos"] = trafos.loc[idx, "angle_reg_n2"] + trafos.loc[idx, "tap2_step_percent"] = trafos.loc[idx, "angle_reg_delta_u"] + trafos.loc[idx, "tap2_step_degree"] = trafos.loc[idx, "angle_reg_theta"] # has to be 90.0° # set the hv and lv voltage sides to voltage1 and voltage2 (The non-regulated transformer side is currently # voltage1, not the hv side!) trafos["vn_hv_kv"] = trafos[["voltage1", "voltage2"]].max(axis=1) trafos["vn_lv_kv"] = trafos[["voltage1", "voltage2"]].min(axis=1) - # swap the 'fid_node_start' and 'fid_node_end' if need + + # swap the 'hv_node' and 'lv_node' if need trafos["swap"] = trafos["vn_hv_kv"] != trafos["voltage1"] + # to be consistent with PF + trafos["swap"] = trafos["swap"] | (trafos["vn_hv_kv"]==trafos["vn_lv_kv"]) + # copy the 'fid_node_start' and 'fid_node_end' trafos["hv_bus2"] = trafos["hv_bus"].copy() trafos["lv_bus2"] = trafos["lv_bus"].copy() trafos.loc[trafos.swap, "hv_bus"] = trafos.loc[trafos.swap, "lv_bus2"] trafos.loc[trafos.swap, "lv_bus"] = trafos.loc[trafos.swap, "hv_bus2"] - # set the tap side, default is lv Correct it for other windings + + # set the tap side, default is lv correct it for other windings trafos["tap_side"] = "lv" trafos["tap2_side"] = "lv" trafos.loc[trafos.swap, "tap_side"] = "hv" trafos.loc[trafos.swap, "tap2_side"] = "hv" - # now set it to nan for not existing tap changers - trafos.loc[trafos.phase_reg_n.isnull(), "tap_side"] = None + trafos["tap_neutral"] = 0 - trafos.loc[trafos.phase_reg_n.isnull(), "tap_neutral"] = np.nan + trafos.loc[trafos.tap_min.isnull(), "tap_side"] = None + trafos.loc[trafos.tap_min.isnull(), "tap_neutral"] = np.nan + trafos.loc[trafos.tap_min.isnull(), "tap_changer_type"] = None + trafos["tap2_neutral"] = 0 + trafos.loc[trafos.tap2_min.isnull(), "tap2_side"] = None + trafos.loc[trafos.tap2_min.isnull(), "tap2_neutral"] = np.nan + trafos["shift_degree"] = 0 trafos["parallel"] = 1 self._fill_empty_names(trafos, "0_x") @@ -670,70 +621,3 @@ def get_name_from_ucte_string(ucte_string: str) -> str: amica_names = input_df.loc[:, input_column].map(get_name_from_ucte_string) input_df.loc[:, "amica_name"] = amica_names - - def set_pp_col_types( - self, - net: pandapowerNet, - ignore_errors: bool = False, - ) -> pandapowerNet: - """ - Set the data types for some columns from pandapower assets. This mainly effects bus columns (to int, e.g. - sgen.bus or line.from_bus) and in_service and other boolean columns (to bool, e.g. line.in_service or gen.slack). - :param net: The pandapower network to update the data types. - :param ignore_errors: Ignore problems if set to True (no warnings displayed). Optional, default: False. - :return: The pandapower network with updated data types. - """ - time_start = time.time() - pp_elements = [ - "bus", - "dcline", - "ext_grid", - "gen", - "impedance", - "line", - "load", - "sgen", - "shunt", - "storage", - "switch", - "trafo", - "trafo3w", - "ward", - "xward", - ] - to_int = ["bus", "element", "to_bus", "from_bus", "hv_bus", "mv_bus", "lv_bus"] - to_bool = ["in_service", "closed"] - self.logger.info( - "Setting the columns data types for buses to int and in_service to bool for the following elements: " - "%s" % pp_elements - ) - int_type = int - bool_type = bool - for ele in pp_elements: - self.logger.info("Accessing pandapower element %s." % ele) - if not hasattr(net, ele): - if not ignore_errors: - self.logger.warning( - "Missing the pandapower element %s in the input pandapower network!" - % ele - ) - continue - for one_int in to_int: - if one_int in net[ele].columns: - self._set_column_to_type(net[ele], one_int, int_type) - for one_bool in to_bool: - if one_bool in net[ele].columns: - self._set_column_to_type(net[ele], one_bool, bool_type) - # some individual things - if hasattr(net, "sgen"): - self._set_column_to_type(net["sgen"], "current_source", bool_type) - if hasattr(net, "gen"): - self._set_column_to_type(net["gen"], "slack", bool_type) - if hasattr(net, "shunt"): - self._set_column_to_type(net["shunt"], "step", int_type) - self._set_column_to_type(net["shunt"], "max_step", int_type) - self.logger.info( - "Finished setting the data types for the pandapower network in %ss." - % (time.time() - time_start) - ) - return net diff --git a/pandapower/converter/ucte/ucte_parser.py b/pandapower/converter/ucte/ucte_parser.py index 487a32cde5..d426b6b194 100644 --- a/pandapower/converter/ucte/ucte_parser.py +++ b/pandapower/converter/ucte/ucte_parser.py @@ -22,12 +22,12 @@ def __init__(self, path_ucte_file: str = None, config: Dict = None): :param config: The configuration dictionary. Optional, default: None. This parameter may be set later. """ self.path_ucte_file: str = path_ucte_file - self.config: Dict = config if isinstance(config, dict) else dict() + self.config: Dict = config if isinstance(config, dict) else {} self.logger = logging.getLogger(self.__class__.__name__) self.ucte_elements = ["##C", "##N", "##L", "##T", "##R", "##TT", "##E"] - self.data: Dict[str, pd.DataFrame] = dict() + self.data: Dict[str, pd.DataFrame] = {} self.date: datetime = datetime.now(timezone.utc) - self.bus_ucte_countries : list = list() + self.bus_ucte_countries : list = [] def parse_file(self, path_ucte_file: str = None) -> bool: """ @@ -54,7 +54,7 @@ def parse_file(self, path_ucte_file: str = None) -> bool: self._parse_date_str(self.config["custom"]["date"]) else: self._parse_date_str(os.path.basename(self.path_ucte_file)[:13]) - raw_input_dict = dict() + raw_input_dict = {} for ucte_element in self.ucte_elements: raw_input_dict[ucte_element] = [] with open(self.path_ucte_file, "r") as f: @@ -67,8 +67,8 @@ def parse_file(self, path_ucte_file: str = None) -> bool: row = row.strip() if row.startswith("##N"): is_in_N = True - elif any([row.startswith(other_ucte_element) for other_ucte_element in - self.ucte_elements if other_ucte_element != "##N"]): + elif any(row.startswith(other_ucte_element) for other_ucte_element in + self.ucte_elements if other_ucte_element != "##N"): is_in_N = False if row in self.ucte_elements: # the start of a new UCTE element type in the origin file @@ -93,15 +93,15 @@ def parse_file(self, path_ucte_file: str = None) -> bool: def _parse_date_str(self, date_str: str): try: - self.date = datetime.strptime(date_str, "%Y%m%d_%H%M") - except Exception as e: + self.date = datetime.datetime.strptime(date_str, "%Y%m%d_%H%M") + except Exception: self.logger.info( f"The given {date_str=} couldn't be parsed as '%Y%m%d_%H%M'.") self.date = datetime.now(timezone.utc) def _create_df_from_raw(self, raw_input_dict): # create DataFrames from the raw_input_dict - self.data = dict() + self.data = {} for ucte_element, items in raw_input_dict.items(): self.data[ucte_element] = pd.DataFrame(items) # make sure that at least some empty data exist @@ -120,66 +120,56 @@ def _create_df_from_raw(self, raw_input_dict): if 0 in df.columns: df = df.drop(columns=[0], axis=1) # set the data types - dtypes = dict() + dtypes = {} i_t = pd.Int64Dtype() - dtypes["##N"] = dict( - { - "status": i_t, - "voltage": float, - "p_load": float, - "q_load": float, - "p_gen": float, - "q_gen": float, - "min_p_gen": float, - "max_p_gen": float, - "min_q_gen": float, - "max_q_gen": float, - "static_primary_control": float, - "p_primary_control": float, - "three_ph_short_circuit_power": float, - "x_r_ratio": float, - "node_type": i_t, - } - ) - dtypes["##L"] = dict( - {"status": i_t, "r": float, "x": float, "b": float, "i": float} - ) - dtypes["##T"] = dict( - { - "status": i_t, - "voltage1": float, - "voltage2": float, - "s": float, - "r": float, - "x": float, - "b": float, - "g": float, - "i": float, - } - ) - dtypes["##R"] = dict( - { - "phase_reg_delta_u": float, - "phase_reg_n": float, - "phase_reg_n2": float, - "phase_reg_u": float, - "angle_reg_delta_u": float, - "angle_reg_theta": float, - "angle_reg_n": float, - "angle_reg_n2": float, - "angle_reg_p": float, - } - ) - dtypes["##TT"] = dict( - { - "tap_position": float, - "r": float, - "x": float, - "delta_u": float, - "alpha": float, - } - ) - dtypes["##E"] = dict({"p": float}) + dtypes["##N"] = { + "status": i_t, + "voltage": float, + "p_load": float, + "q_load": float, + "p_gen": float, + "q_gen": float, + "min_p_gen": float, + "max_p_gen": float, + "min_q_gen": float, + "max_q_gen": float, + "static_primary_control": float, + "p_primary_control": float, + "three_ph_short_circuit_power": float, + "x_r_ratio": float, + "node_type": i_t, + } + dtypes["##L"] = {"status": i_t, "r": float, "x": float, "b": float, "i": float} + dtypes["##T"] = { + "status": i_t, + "voltage1": float, + "voltage2": float, + "s": float, + "r": float, + "x": float, + "b": float, + "g": float, + "i": float, + } + dtypes["##R"] = { + "phase_reg_delta_u": float, + "phase_reg_n": float, + "phase_reg_n2": float, + "phase_reg_u": float, + "angle_reg_delta_u": float, + "angle_reg_theta": float, + "angle_reg_n": float, + "angle_reg_n2": float, + "angle_reg_p": float, + } + dtypes["##TT"] = { + "tap_position": float, + "r": float, + "x": float, + "delta_u": float, + "alpha": float, + } + dtypes["##E"] = {"p": float} for ucte_element, one_dtypes in dtypes.items(): for field, field_type in one_dtypes.items(): self.data[ucte_element].loc[ @@ -207,8 +197,6 @@ def _split_nodes_from_raw(self): self.logger.warning("No nodes in 'self.data' available! Didn't split them.") return df = self.data[element_type] - # if 0 not in df.columns: - # df[0] = "" df["node"] = df[0].str[0:8].str.strip() df["node_name"] = df[0].str[9:21].str.strip() df["status"] = df[0].str[22:23].str.strip() @@ -348,14 +336,14 @@ def get_fields_dict(self) -> Dict[str, Dict[str, List[str]]]: with tempfile.NamedTemporaryFile(delete=False) as f: f.close() ucte_temp = UCTEParser() - ucte_temp.set_config(dict({"custom": {"date": "20200701_1010"}})) + ucte_temp.set_config({"custom": {"date": "20200701_1010"}}) ucte_temp.parse_file(path_ucte_file=f.name) data = ucte_temp.get_data() if os.path.exists(f.name): os.remove(f.name) - return_dict = dict() - return_dict["element_types"] = dict() - return_dict["dtypes"] = dict() + return_dict = {} + return_dict["element_types"] = {} + return_dict["dtypes"] = {} for element_type, df in data.items(): return_dict["element_types"][element_type] = list(df.columns) return_dict["dtypes"][element_type] = [str(x) for x in df.dtypes.values] @@ -374,7 +362,7 @@ def set_config(self, config: Dict): self.logger.warning( "The configuration is not a dictionary! Default configuration is set." ) - self.config = dict() + self.config = {} def get_config(self) -> Dict: return self.config diff --git a/pandapower/create/_utils.py b/pandapower/create/_utils.py index 7129ac9253..54306c0522 100644 --- a/pandapower/create/_utils.py +++ b/pandapower/create/_utils.py @@ -7,27 +7,77 @@ import logging import warnings -from typing import Iterable +from typing import Iterable, Any import pandas as pd -from numpy import nan, isnan, arange, isin, any as np_any, all as np_all, float64, intersect1d, unique as uni, c_ +from numpy import isnan, arange, isin, any as np_any, all as np_all, intersect1d, unique as uni, c_ import numpy.typing as npt from pandas import isnull from pandas.api.types import is_object_dtype from pandapower.auxiliary import ( + ADict, pandapowerNet, get_free_id, _preserve_dtypes, ensure_iterability, empty_defaults_per_dtype, ) -from pandapower.plotting.geo import _is_valid_number from pandapower.pp_types import Int +from pandapower.network_structure import get_structure_dict, get_column_info logger = logging.getLogger(__name__) +def add_column_to_df(net: ADict, table_name: str, column_name: str) -> None: + """ + Adds column to table if not present, if table not present adds table + Only works for columns that are defined in the network structure dict + """ + if table_name in net and column_name in net[table_name]: + return + # Add Table: + net_struct_dict = get_structure_dict() + if table_name not in net: + if table_name not in net_struct_dict: + raise ValueError(f"Table {table_name} has no definition in network structure.") + net[table_name] = pd.DataFrame( + columns=net_struct_dict[table_name].keys(), dtype=net_struct_dict[table_name].values() + ) + # Add Optional Column: + net_struct_dict = get_structure_dict(False) + dtype = net_struct_dict[table_name][column_name] + net[table_name][column_name] = pd.Series(dtype=dtype) + # Ensure column order: + desired_order = list(net_struct_dict[table_name].keys()) + struct_columns = [col for col in desired_order if col in net[table_name].columns] + custom_columns = [col for col in net[table_name].columns if col not in desired_order] + net[table_name] = net[table_name][struct_columns+custom_columns] + + +def _geodata_to_geo_series(data: Iterable[tuple[float, float]] | tuple[int, int], nr_buses: int) -> list[str]: + from pandapower.plotting.geo import _is_valid_number + geo = [] + for g in data: + if isinstance(g, tuple): + if len(g) != 2: + raise ValueError("geodata tuples must be of length 2") + elif not _is_valid_number(g[0]): + raise UserWarning("geodata x must be a valid number") + elif not _is_valid_number(g[1]): + raise UserWarning("geodata y must be a valid number") + else: + x, y = g + geo.append(f'{{"coordinates": [{x}, {y}], "type": "Point"}}') + else: + raise ValueError("geodata must be iterable of tuples of (x, y) coordinates") + if len(geo) == 1: + geo = [geo[0]] * nr_buses + if len(geo) != nr_buses: + raise ValueError("geodata must be a single point or have the same length as nr_buses") + return geo + + def _group_parameter_list(element_types, elements, reference_columns): """ Ensures that element_types, elements and reference_columns are iterables with same lengths. @@ -181,7 +231,7 @@ def _not_nan(value, all_=True): return not any(isnan(value)) else: try: - return not (value is None or isnan(value)) + return pd.notna(value) except TypeError: return True @@ -193,38 +243,35 @@ def _try_astype(df, column, dtyp): pass -def _set_value_if_not_nan(net, index, value, column, element_type, dtype=float64, default_val=nan): +def _set_value_if_not_nan( + net: pandapowerNet, index: int, value: Any, column: str, element_type: str, default_val=pd.NA +): """Sets the given value to the dataframe net[element_type]. If the value is nan, default_val is assumed if this is not nan. If the value is not nan and the column does not exist already, the column is created and filled by default_val. - Parameters - ---------- - net : pp.pandapowerNet - pp net - index : int - index of the element to get a value - value : Any - value to be set - column : str - name of column - element_type : str - element_type type, e.g. "gen" - dtype : Any, optional - e.g. float64, "Int64", bool_, ..., by default float64 - default_val : Any, optional - default value to be set if the column exists and value is nan and if the column does not - exist and the value is not nan, by default nan + Parameters: + net: the pandapower net + index: index of the element to get a value + value: value to be set + column: name of column + element_type: element_type type, e.g. "gen" + default_val: default value to be set for this column (if not passed, attempt to take from pandera) - See Also - -------- - _add_to_entries_if_not_nan + See Also: + _add_to_entries_if_not_nan """ column_exists = column in net[element_type].columns + dtype = get_structure_dict(required_only=False)[element_type][column] + col_info = get_column_info(element_type, column) + if col_info is not None and pd.isna(default_val) and not col_info["nullable"] and col_info["default"] is not None: + default_val = col_info["default"] + if dtype == "float" and pd.isna(default_val): + default_val = float("nan") if _not_nan(value): if not column_exists: - net[element_type].loc[:, column] = pd.Series(data=default_val, index=net[element_type].index) + net[element_type][column] = pd.Series(data=default_val, index=net[element_type].index) _try_astype(net[element_type], column, dtype) net[element_type].at[index, column] = value elif column_exists: @@ -233,7 +280,9 @@ def _set_value_if_not_nan(net, index, value, column, element_type, dtype=float64 _try_astype(net[element_type], column, dtype) -def _add_to_entries_if_not_nan(net, element_type, entries, index, column, values, dtype=float64, default_val=nan): +def _add_to_entries_if_not_nan( + net: pandapowerNet, element_type, entries, index: int, column, values, dtype=None, default_val=pd.NA +): """ See Also @@ -241,6 +290,10 @@ def _add_to_entries_if_not_nan(net, element_type, entries, index, column, values _set_value_if_not_nan """ column_exists = column in net[element_type].columns + dtype = get_structure_dict(required_only=False)[element_type][column] + col_info = get_column_info(element_type, column) + if col_info is not None and pd.isna(default_val) and not col_info["nullable"] and col_info["default"] is not None: + default_val = col_info["default"] if _not_nan(values): entries[column] = pd.Series(values, index=index) if _not_nan(default_val): @@ -252,6 +305,7 @@ def _add_to_entries_if_not_nan(net, element_type, entries, index, column, values def _branch_geodata(geodata: Iterable[list[float] | tuple[float, float]]) -> list[list[float]]: + from pandapower.plotting.geo import _is_valid_number geo: list[list[float]] = [] for x, y in geodata: if (not _is_valid_number(x)) | (not _is_valid_number(y)): @@ -265,14 +319,18 @@ def _add_branch_geodata(net: pandapowerNet, geodata, index, table="line"): if not isinstance(geodata, (list, tuple)): raise ValueError("geodata needs to be list or tuple") geodata = f'{{"coordinates": {_branch_geodata(geodata)}, "type": "LineString"}}' + elif "geo" in net[table].columns: + return else: - geodata = None + geodata = pd.NA net[table].loc[index, "geo"] = geodata + net[table]["geo"] = net[table]["geo"].astype(get_structure_dict(required_only=False)[table]["geo"]) def _add_multiple_branch_geodata(net, geodata, index, table="line"): + dtype = get_structure_dict(required_only=False)[table]["geo"] if not geodata: - net[table].loc[index, "geo"] = None + net[table].loc[index, "geo"] = pd.Series(data=[pd.NA] * len(net[table]), index=net[table].index, dtype=dtype) return dtypes = net[table].dtypes if hasattr(geodata, "__iter__") and all(isinstance(g, tuple) and len(g) == 2 for g in geodata): @@ -282,13 +340,13 @@ def _add_multiple_branch_geodata(net, geodata, index, table="line"): elif hasattr(geodata, "__iter__") and all(isinstance(g, Iterable) for g in geodata): # geodata is Iterable of coordinate tuples geo = [[[x, y] for x, y in g] for g in geodata] - series = pd.Series([f'{{"coordinates": {g}, "type": "LineString"}}' for g in geo], index=index) + series = [f'{{"coordinates": {g}, "type": "LineString"}}' for g in geo] else: raise ValueError( "geodata must be an Iterable of Iterable of coordinate tuples or an Iterable of coordinate tuples" ) - net[table].loc[index, "geo"] = series + net[table].loc[index, "geo"] = pd.Series(series, index=index, dtype=dtype) _preserve_dtypes(net[table], dtypes) @@ -303,7 +361,18 @@ def _set_entries(net, table, index, preserve_dtypes=True, entries: dict | None = dtypes = net[table][intersect1d(net[table].columns, list(entries))].dtypes for col, val in entries.items(): - net[table].at[index, col] = val + val_not_na: bool = pd.notna(val) if pd.api.types.is_scalar(val) else pd.notna(val).any() + if val_not_na: + net[table].at[index, col] = val + try: + dtype = get_structure_dict(required_only=False)[table][col] + if ( + dtype == bool and net[table][col].isna().any() + ): # default value for bool entries # TODO: check if wanted behaviour + net[table][col] = net[table][col].astype(pd.BooleanDtype()).fillna(False) + net[table][col] = net[table][col].astype(dtype) + except KeyError as e: + logger.error(f"column {col} has no dtype in network structure") # and preserve dtypes if preserve_dtypes: @@ -317,6 +386,7 @@ def _check_entry(val, index): return list(val) return val + def _set_multiple_entries( net: pandapowerNet, table: str, @@ -338,12 +408,21 @@ def _set_multiple_entries( dd = pd.DataFrame(index=index, columns=net[table].columns) dd = dd.assign(**entries) + dtype_dict = get_structure_dict(required_only=False)[table] + # defaults_to_fill needed due to pandas bug https://github.com/pandas-dev/pandas/issues/46662: # concat adds new bool columns as object dtype -> fix it by setting default value to net[table] if defaults_to_fill is not None: for col, val in defaults_to_fill: if col in dd.columns and col not in net[table].columns: net[table][col] = val + if col in dtype_dict: + net[table][col] = net[table][col].astype(dtype_dict[col]) + + # set correct dtypes + for col in dd.columns: + if col in dtype_dict: + dd[col] = dd[col].astype(dtype_dict[col]) # extend the table by the frame we just created if len(net[table]): diff --git a/pandapower/create/bus_create.py b/pandapower/create/bus_create.py index 0a39a938ed..126ba9d95f 100644 --- a/pandapower/create/bus_create.py +++ b/pandapower/create/bus_create.py @@ -8,10 +8,12 @@ import logging from typing import Iterable +import pandas as pd from numpy import nan import numpy.typing as npt from pandapower.auxiliary import pandapowerNet +from pandapower.network_structure import get_default_value from pandapower.plotting.geo import _is_valid_number from pandapower.pp_types import BusType, Int from pandapower.create._utils import ( @@ -70,9 +72,9 @@ def create_bus( name: str | None = None, index: Int | None = None, geodata: tuple[float, float] | None = None, - type: BusType = "b", + type: BusType = get_default_value("bus", "type"), zone: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("bus", "in_service"), max_vm_pu: float = nan, min_vm_pu: float = nan, coords: list[list[float]] | None = None, @@ -112,8 +114,16 @@ def create_bus( _set_entries(net, "bus", index, True, entries=entries) # column needed by OPF. 0. and 2. are the default maximum / minimum voltages - _set_value_if_not_nan(net, index, min_vm_pu, "min_vm_pu", "bus", default_val=0.0) - _set_value_if_not_nan(net, index, max_vm_pu, "max_vm_pu", "bus", default_val=2.0) + if pd.notna(min_vm_pu) or pd.notna(max_vm_pu) or "min_vm_pu" in net.bus.columns or "max_vm_pu" in net.bus.columns: + if "min_vm_pu" not in net.bus.columns or "max_vm_pu" not in net.bus.columns: + net.bus["min_vm_pu"] = get_default_value("bus", "min_vm_pu") + net.bus["max_vm_pu"] = get_default_value("bus", "max_vm_pu") + _set_value_if_not_nan( + net, index, min_vm_pu, "min_vm_pu", "bus", default_val=get_default_value("bus", "min_vm_pu") + ) + _set_value_if_not_nan( + net, index, max_vm_pu, "max_vm_pu", "bus", default_val=get_default_value("bus", "max_vm_pu") + ) return index @@ -124,9 +134,9 @@ def create_bus_dc( name: str | None = None, index: Int | None = None, geodata: tuple[float, float] | None = None, - type: BusType = "b", + type: BusType = get_default_value("bus_dc", "type"), zone: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("bus_dc", "in_service"), max_vm_pu: float = nan, min_vm_pu: float = nan, coords: list[list[float]] | None = None, @@ -167,8 +177,13 @@ def create_bus_dc( _set_entries(net, "bus_dc", index, True, entries=entries) # column needed by OPF. 0. and 2. are the default maximum / minimum voltages - _set_value_if_not_nan(net, index, min_vm_pu, "min_vm_pu", "bus_dc", default_val=0.0) - _set_value_if_not_nan(net, index, max_vm_pu, "max_vm_pu", "bus_dc", default_val=2.0) + if pd.notna(min_vm_pu) or pd.notna(max_vm_pu) or "min_vm_pu" in net.bus.columns: + _set_value_if_not_nan( + net, index, min_vm_pu, "min_vm_pu", "bus_dc", default_val=get_default_value("bus_dc", "min_vm_pu") + ) + _set_value_if_not_nan( + net, index, max_vm_pu, "max_vm_pu", "bus_dc", default_val=get_default_value("bus_dc", "max_vm_pu") + ) return index @@ -179,10 +194,10 @@ def create_buses( vn_kv: float | Iterable[float], index: Int | Iterable[Int] | None = None, name: Iterable[str] | None = None, - type: BusType | Iterable[BusType] = "b", + type: BusType | Iterable[BusType] = get_default_value("bus", "type"), geodata: tuple[float, float] | Iterable[tuple[float, float]] | None = None, zone: str | Iterable[str] | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("bus", "in_service"), max_vm_pu: float | Iterable[float] = nan, min_vm_pu: float | Iterable[float] = nan, coords: list[list[list[float]]] | None = None, @@ -227,11 +242,30 @@ def create_buses( geo = _geodata_to_geo_series(geodata, coords, nr_buses) entries = {"vn_kv": vn_kv, "type": type, "zone": zone, "in_service": in_service, "name": name, "geo": geo, **kwargs} - _add_to_entries_if_not_nan(net, "bus", entries, index, "min_vm_pu", min_vm_pu) - _add_to_entries_if_not_nan(net, "bus", entries, index, "max_vm_pu", max_vm_pu) + + min_vm_pu_exists = pd.notna(min_vm_pu) if pd.api.types.is_scalar(min_vm_pu) else pd.notna(min_vm_pu).any() + max_vm_pu_exists = pd.notna(max_vm_pu) if pd.api.types.is_scalar(max_vm_pu) else pd.notna(max_vm_pu).any() + if min_vm_pu_exists or max_vm_pu_exists or "min_vm_pu" in net.bus.columns: + _add_to_entries_if_not_nan( + net, + "bus", + entries, + index, + "min_vm_pu", + min_vm_pu, + default_val=get_default_value("bus", "min_vm_pu"), + ) + _add_to_entries_if_not_nan( + net, + "bus", + entries, + index, + "max_vm_pu", + max_vm_pu, + default_val=get_default_value("bus", "max_vm_pu"), + ) _set_multiple_entries(net, "bus", index, entries=entries) - if "geo" in net.bus.columns: - net.bus.loc[net.bus.geo == "", "geo"] = None # overwrite + return index @@ -241,10 +275,10 @@ def create_buses_dc( vn_kv: float | Iterable[float], index: Int | Iterable[Int] | None = None, name: Iterable[str] | None = None, - type: BusType | Iterable[BusType] = "b", + type: BusType | Iterable[BusType] = get_default_value("bus_dc", "type"), geodata: Iterable[tuple[float, float]] | None = None, zone: str | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("bus_dc", "in_service"), max_vm_pu: float | Iterable[float] = nan, min_vm_pu: float | Iterable[float] = nan, coords: list[list[list[float]]] | None = None, @@ -291,8 +325,28 @@ def create_buses_dc( geo = _geodata_to_geo_series(geodata, coords, nr_buses_dc) entries = {"vn_kv": vn_kv, "type": type, "zone": zone, "in_service": in_service, "name": name, "geo": geo, **kwargs} - _add_to_entries_if_not_nan(net, "bus_dc", entries, index, "min_vm_pu", min_vm_pu) - _add_to_entries_if_not_nan(net, "bus_dc", entries, index, "max_vm_pu", max_vm_pu) + + min_vm_pu_exists = pd.notna(min_vm_pu) if pd.api.types.is_scalar(min_vm_pu) else pd.notna(min_vm_pu).any() + max_vm_pu_exists = pd.notna(max_vm_pu) if pd.api.types.is_scalar(max_vm_pu) else pd.notna(max_vm_pu).any() + if min_vm_pu_exists or max_vm_pu_exists or "min_vm_pu" in net.bus.columns: + _add_to_entries_if_not_nan( + net, + "bus_dc", + entries, + index, + "min_vm_pu", + min_vm_pu, + default_val=get_default_value("bus_dc", "min_vm_pu"), + ) + _add_to_entries_if_not_nan( + net, + "bus_dc", + entries, + index, + "max_vm_pu", + max_vm_pu, + default_val=get_default_value("bus_dc", "max_vm_pu"), + ) _set_multiple_entries(net, "bus_dc", index, entries=entries) return index diff --git a/pandapower/create/ext_grid_create.py b/pandapower/create/ext_grid_create.py index 9ed4bfe20a..dc71998162 100644 --- a/pandapower/create/ext_grid_create.py +++ b/pandapower/create/ext_grid_create.py @@ -10,6 +10,7 @@ from numpy import nan, bool_ from pandapower.auxiliary import pandapowerNet +from pandapower.network_structure import get_default_value from pandapower.pp_types import Int from pandapower.create._utils import ( _check_element, @@ -24,10 +25,10 @@ def create_ext_grid( net: pandapowerNet, bus: Int, - vm_pu: float = 1.0, - va_degree: float = 0.0, + vm_pu: float = get_default_value("ext_grid", "vm_pu"), + va_degree: float = get_default_value("ext_grid", "va_degree"), name: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("ext_grid", "in_service"), s_sc_max_mva: float = nan, s_sc_min_mva: float = nan, rx_max: float = nan, @@ -40,7 +41,7 @@ def create_ext_grid( r0x0_max: float = nan, x0x_max: float = nan, controllable: bool | float = nan, - slack_weight: float = 1.0, + slack_weight: float = get_default_value("ext_grid", "slack_weight"), **kwargs, ) -> Int: """ @@ -108,7 +109,9 @@ def create_ext_grid( _set_value_if_not_nan(net, index, max_p_mw, "max_p_mw", "ext_grid") _set_value_if_not_nan(net, index, min_q_mvar, "min_q_mvar", "ext_grid") _set_value_if_not_nan(net, index, max_q_mvar, "max_q_mvar", "ext_grid") - _set_value_if_not_nan(net, index, controllable, "controllable", "ext_grid", dtype=bool_, default_val=False) + _set_value_if_not_nan( + net, index, controllable, "controllable", "ext_grid", default_val=get_default_value("ext_grid", "controllable") + ) # others _set_value_if_not_nan(net, index, x0x_max, "x0x_max", "ext_grid") _set_value_if_not_nan(net, index, r0x0_max, "r0x0_max", "ext_grid") diff --git a/pandapower/create/gen_create.py b/pandapower/create/gen_create.py index 8b4a56b835..fc59cc9e40 100644 --- a/pandapower/create/gen_create.py +++ b/pandapower/create/gen_create.py @@ -8,10 +8,12 @@ import logging from typing import Iterable, Sequence +import pandas as pd from numpy import nan, bool_ import numpy.typing as npt from pandapower.auxiliary import pandapowerNet +from pandapower.network_structure import get_default_value from pandapower.pp_types import Int from pandapower.create._utils import ( _add_to_entries_if_not_nan, @@ -31,9 +33,9 @@ def create_gen( net: pandapowerNet, bus: Int, p_mw: float, - vm_pu: float = 1.0, + vm_pu: float = get_default_value("gen", "vm_pu"), sn_mva: float = nan, - name: str | None = None, + name: str = pd.NA, index: Int | None = None, max_q_mvar: float = nan, min_q_mvar: float = nan, @@ -41,21 +43,21 @@ def create_gen( max_p_mw: float = nan, min_vm_pu: float = nan, max_vm_pu: float = nan, - scaling: float = 1.0, - type: str | None = None, - slack: bool = False, - id_q_capability_characteristic: int | None = None, - reactive_capability_curve: bool = False, - curve_style: str | None = None, - controllable: bool | None = None, + scaling: float = get_default_value("gen", "scaling"), + type: str = pd.NA, + slack: bool = get_default_value("gen", "slack"), + id_q_capability_characteristic: int | None = pd.NA, + reactive_capability_curve: bool | None = None, + curve_style: str | None = pd.NA, + controllable: bool | Iterable[bool] | None = pd.NA, vn_kv: float = nan, xdss_pu: float = nan, rdss_ohm: float = nan, cos_phi: float = nan, pg_percent: float = nan, - power_station_trafo: int | float = nan, - in_service: bool = True, - slack_weight: float = 0.0, + power_station_trafo: int = pd.NA, + in_service: bool = get_default_value("gen", "in_service"), + slack_weight: float = nan, **kwargs, ) -> Int: """ @@ -96,24 +98,24 @@ def create_gen( power_station_trafo: Index of the power station transformer for shortcircuit calculation in_service: True for in_service or False for out of service max_p_mw: Maximum active power injection - + - necessary for OPF - + min_p_mw: Minimum active power injection - + - necessary for OPF - + max_q_mvar: Maximum reactive power injection - + - necessary for OPF - + min_q_mvar: Minimum reactive power injection - + - necessary for OPF - + min_vm_pu: Minimum voltage magnitude. If not set, the bus voltage limit is taken - necessary for OPF. max_vm_pu: Maximum voltage magnitude. If not set, the bus voltage limit is taken - necessary for OPF. - + Returns: The ID of the created generator @@ -140,17 +142,24 @@ def create_gen( _set_entries(net, "gen", index, True, entries=entries) # OPF limits - _set_value_if_not_nan(net, index, controllable, "controllable", "gen", dtype=bool_, default_val=True) - - # id for q capability curve table _set_value_if_not_nan( - net, index, id_q_capability_characteristic, "id_q_capability_characteristic", "gen", dtype="Int64" + net, index, controllable, "controllable", "gen", default_val=get_default_value("gen", "controllable") ) + # id for q capability curve table + _set_value_if_not_nan(net, index, id_q_capability_characteristic, "id_q_capability_characteristic", "gen") + # behaviour of reactive power capability curve - _set_value_if_not_nan(net, index, curve_style, "curve_style", "gen", dtype=object, default_val=None) + _set_value_if_not_nan(net, index, curve_style, "curve_style", "gen") - _set_value_if_not_nan(net, index, reactive_capability_curve, "reactive_capability_curve", "gen", dtype=bool_) + _set_value_if_not_nan( + net, + index, + reactive_capability_curve, + "reactive_capability_curve", + "gen", + default_val=get_default_value("gen", "reactive_capability_curve"), + ) # P limits for OPF if controllable == True _set_value_if_not_nan(net, index, min_p_mw, "min_p_mw", "gen") @@ -159,8 +168,8 @@ def create_gen( _set_value_if_not_nan(net, index, min_q_mvar, "min_q_mvar", "gen") _set_value_if_not_nan(net, index, max_q_mvar, "max_q_mvar", "gen") # V limits for OPF if controllable == True - _set_value_if_not_nan(net, index, max_vm_pu, "max_vm_pu", "gen", default_val=2.0) - _set_value_if_not_nan(net, index, min_vm_pu, "min_vm_pu", "gen", default_val=0.0) + _set_value_if_not_nan(net, index, max_vm_pu, "max_vm_pu", "gen", default_val=get_default_value("gen", "max_vm_pu")) + _set_value_if_not_nan(net, index, min_vm_pu, "min_vm_pu", "gen", default_val=get_default_value("gen", "min_vm_pu")) # Short circuit calculation variables _set_value_if_not_nan(net, index, vn_kv, "vn_kv", "gen") @@ -168,7 +177,7 @@ def create_gen( _set_value_if_not_nan(net, index, xdss_pu, "xdss_pu", "gen") _set_value_if_not_nan(net, index, rdss_ohm, "rdss_ohm", "gen") _set_value_if_not_nan(net, index, pg_percent, "pg_percent", "gen") - _set_value_if_not_nan(net, index, power_station_trafo, "power_station_trafo", "gen", dtype="Int64") + _set_value_if_not_nan(net, index, power_station_trafo, "power_station_trafo", "gen") return index @@ -177,7 +186,7 @@ def create_gens( net: pandapowerNet, buses: Sequence, p_mw: float | Iterable[float], - vm_pu: float | Iterable[float] = 1.0, + vm_pu: float | Iterable[float] = get_default_value("gen", "vm_pu"), sn_mva: float | Iterable[float] = nan, name: Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, @@ -187,21 +196,21 @@ def create_gens( max_p_mw: float | Iterable[float] = nan, min_vm_pu: float | Iterable[float] = nan, max_vm_pu: float | Iterable[float] = nan, - scaling: float | Iterable[float] = 1.0, - type: str | Iterable[str] | None = None, - slack: bool | Iterable[bool] = False, - id_q_capability_characteristic: Int | Iterable[Int] | None = None, - reactive_capability_curve: bool | Iterable[bool] = False, - curve_style: str | Iterable[str] | None = None, - controllable: bool | Iterable[bool] | None = None, + scaling: float | Iterable[float] = get_default_value("gen", "scaling"), + type: str | Iterable[str] = pd.NA, + slack: bool | Iterable[bool] = get_default_value("gen", "slack"), + id_q_capability_characteristic: Int | Iterable[Int] | None = pd.NA, + reactive_capability_curve: bool | Iterable[bool] | None = None, + curve_style: str | Iterable[str] | None = pd.NA, + controllable: bool | float | Iterable[bool | float] | None = None, vn_kv: float | Iterable[float] = nan, xdss_pu: float | Iterable[float] = nan, rdss_ohm: float | Iterable[float] = nan, cos_phi: float | Iterable[float] = nan, pg_percent: float = nan, - power_station_trafo: int | float = nan, - in_service: bool = True, - slack_weight: float = 0.0, + power_station_trafo: int = pd.NA, + in_service: bool = get_default_value("gen", "in_service"), + slack_weight: float = nan, **kwargs, ) -> npt.NDArray[Int]: """ @@ -230,10 +239,10 @@ def create_gens( power (Q). It indicates whether the reactive power remains constant as the active power changes or varies dynamically in response to it. e.g. "straightLineYValues" and "constantYValue" controllable: - + - True: p_mw, q_mvar and vm_pu limits are enforced for this generator in OPF - False: p_mw and vm_pu set points are enforced and *limits are ignored*. - + defaults to True if "controllable" column exists in DataFrame vn_kv: Rated voltage of the generator for shortcircuit calculation @@ -245,27 +254,27 @@ def create_gens( in_service: True for in_service or False for out of service slack_weight: Contribution factor for distributed slack power flow calculation (active power balancing) max_p_mw: Maximum active power injection - + - necessary for OPF - + min_p_mw: Minimum active power injection - + - necessary for OPF - + max_q_mvar: Maximum reactive power injection - + - necessary for OPF - + min_q_mvar: Minimum reactive power injection - + - necessary for OPF - + min_vm_pu: Minimum voltage magnitude. If not set the bus voltage limit is taken. - + - necessary for OPF. - + max_vm_pu: Maximum voltage magnitude. If not set the bus voltage limit is taken. - + - necessary for OPF Returns: @@ -290,7 +299,6 @@ def create_gens( "type": type, "slack": slack, "curve_style": curve_style, - "reactive_capability_curve": reactive_capability_curve, **kwargs, } @@ -306,17 +314,23 @@ def create_gens( _add_to_entries_if_not_nan(net, "gen", entries, index, "rdss_ohm", rdss_ohm) _add_to_entries_if_not_nan(net, "gen", entries, index, "pg_percent", pg_percent) _add_to_entries_if_not_nan( - net, "gen", entries, index, "id_q_capability_characteristic", id_q_capability_characteristic, dtype="Int64" + net, "gen", entries, index, "id_q_capability_characteristic", id_q_capability_characteristic ) - _add_to_entries_if_not_nan( - net, "gen", entries, index, "reactive_capability_curve", reactive_capability_curve, dtype=bool_ - ) - - _add_to_entries_if_not_nan(net, "gen", entries, index, "power_station_trafo", power_station_trafo, dtype="Int64") - _add_to_entries_if_not_nan(net, "gen", entries, index, "controllable", controllable, dtype=bool_, default_val=True) - defaults_to_fill = [("controllable", True), ("reactive_capability_curve", False), ("curve_style", None)] - - _set_multiple_entries(net, "gen", index, defaults_to_fill=defaults_to_fill, entries=entries) + if "reactive_capability_curve" in net.gen or reactive_capability_curve is not None: + _add_to_entries_if_not_nan( + net, + "gen", + entries, + index, + "reactive_capability_curve", + reactive_capability_curve, + default_val=get_default_value("gen", "reactive_capability_curve"), + ) + + _add_to_entries_if_not_nan(net, "gen", entries, index, "power_station_trafo", power_station_trafo) + _add_to_entries_if_not_nan(net, "gen", entries, index, "controllable", controllable) + + _set_multiple_entries(net, "gen", index, entries=entries, defaults_to_fill=[("controllable", False)]) return index diff --git a/pandapower/create/impedance_create.py b/pandapower/create/impedance_create.py index 98946cd32b..f5780bfae9 100644 --- a/pandapower/create/impedance_create.py +++ b/pandapower/create/impedance_create.py @@ -12,6 +12,7 @@ import numpy.typing as npt from pandapower.auxiliary import pandapowerNet +from pandapower.network_structure import get_default_value from pandapower.pp_types import Int from pandapower.create._utils import ( _check_branch_element, @@ -42,8 +43,8 @@ def create_impedance( xft0_pu: float | None = None, rtf0_pu: float | None = None, xtf0_pu: float | None = None, - gf_pu: float | None = 0, - bf_pu: float | None = 0, + gf_pu: float | None = get_default_value("impedance", "gf_pu"), + bf_pu: float | None = get_default_value("impedance", "bf_pu"), gt_pu: float | None = None, bt_pu: float | None = None, gf0_pu: float | None = None, @@ -219,8 +220,8 @@ def create_impedances( xft0_pu: float | Iterable[float] | None = None, rtf0_pu: float | Iterable[float] | None = None, xtf0_pu: float | Iterable[float] | None = None, - gf_pu: float | Iterable[float] | None = 0, - bf_pu: float | Iterable[float] | None = 0, + gf_pu: float | Iterable[float] | None = get_default_value("impedance", "gf_pu"), + bf_pu: float | Iterable[float] | None = get_default_value("impedance", "bf_pu"), gt_pu: float | Iterable[float] | None = None, bt_pu: float | Iterable[float] | None = None, gf0_pu: float | Iterable[float] | None = None, @@ -388,11 +389,11 @@ def create_tcsc( set_p_to_mw: float, thyristor_firing_angle_degree: float, name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("tcsc", "controllable"), + in_service: bool = get_default_value("tcsc", "in_service"), index: Int | None = None, - min_angle_degree: float = 90, - max_angle_degree: float = 180, + min_angle_degree: float = get_default_value("tcsc", "min_angle_degree"), + max_angle_degree: float = get_default_value("tcsc", "max_angle_degree"), **kwargs, ) -> Int: """ @@ -458,7 +459,7 @@ def create_series_reactor_as_impedance( x_ohm: float, sn_mva: float, name: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("impedance", "in_service"), index: int | None = None, r0_ohm: float | None = None, x0_ohm: float | None = None, @@ -466,7 +467,7 @@ def create_series_reactor_as_impedance( ) -> Int: """ Creates a series reactor as per-unit impedance - + Parameters: net: The pandapower network in which the element is created from_bus: starting bus of the series reactor @@ -477,7 +478,7 @@ def create_series_reactor_as_impedance( name: in_service: index: - + Returns: index of the created element """ diff --git a/pandapower/create/line_create.py b/pandapower/create/line_create.py index 23668b0498..8c1cb08241 100644 --- a/pandapower/create/line_create.py +++ b/pandapower/create/line_create.py @@ -9,12 +9,10 @@ from operator import itemgetter from typing import Iterable, Sequence -from numpy import nan, isnan, any as np_any, bool_, all as np_all, float64 import numpy.typing as npt +from numpy import nan, isnan, any as np_any, all as np_all from pandapower.auxiliary import pandapowerNet -from pandapower.std_types import load_std_type -from pandapower.pp_types import Int, LineType from pandapower.create._utils import ( _add_branch_geodata, _add_multiple_branch_geodata, @@ -27,6 +25,9 @@ _set_multiple_entries, _set_value_if_not_nan, ) +from pandapower.network_structure import get_default_value +from pandapower.pp_types import Int, LineType +from pandapower.std_types import load_std_type logger = logging.getLogger(__name__) @@ -40,9 +41,9 @@ def create_line( name: str | None = None, index: Int | None = None, geodata: Iterable[tuple[float, float]] | None = None, - df: float = 1.0, - parallel: int = 1, - in_service: bool = True, + df: float = get_default_value("line", "df"), + parallel: int = get_default_value("line", "parallel"), + in_service: bool = get_default_value("line", "in_service"), max_loading_percent: float = nan, alpha: float = nan, temperature_degree_celsius: float = nan, @@ -72,7 +73,7 @@ def create_line( max_loading_percent: maximum current loading (only needed for OPF) alpha: temperature coefficient of resistance: R(T) = R(T_0) * (1 + alpha * (T - T_0)) temperature_degree_celsius: line temperature for which line resistance is adjusted - + Keyword arguments: tdpf (bool): whether the line is considered in the TDPF calculation wind_speed_m_per_s (float): wind speed at the line in m/s (TDPF) @@ -136,7 +137,7 @@ def create_line( if "type" in lineparam: entries["type"] = lineparam["type"] - # if net.line column already has alpha, add it from std_type + # only add alpha from std_type if any line already has an alpha # TODO inconsistent behavior: Document this CLEARLY! if "alpha" in net.line.columns and "alpha" in lineparam: entries["alpha"] = lineparam["alpha"] @@ -146,9 +147,9 @@ def create_line( _set_value_if_not_nan(net, index, alpha, "alpha", "line") _set_value_if_not_nan(net, index, temperature_degree_celsius, "temperature_degree_celsius", "line") # add optional columns for TDPF if parameters passed to kwargs: - _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line", bool_) + _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line") for column, value in tdpf_parameters.items(): - _set_value_if_not_nan(net, index, value, column, "line", float64) + _set_value_if_not_nan(net, index, value, column, "line") _add_branch_geodata(net, geodata, index) @@ -164,9 +165,9 @@ def create_line_dc( name: str | None = None, index: Int | None = None, geodata: Iterable[tuple[float, float]] | None = None, - df: float = 1.0, - parallel: int = 1, - in_service: bool = True, + df: float = get_default_value("line", "df"), + parallel: int = get_default_value("line", "parallel"), + in_service: bool = get_default_value("line", "in_service"), max_loading_percent: float = nan, alpha: float = nan, temperature_degree_celsius: float = nan, @@ -195,7 +196,7 @@ def create_line_dc( max_loading_percent: maximum current loading (only needed for OPF) alpha: temperature coefficient of resistance: R(T) = R(T_0) * (1 + alpha * (T - T_0)) temperature_degree_celsius: line temperature for which line resistance is adjusted - + Keyword Arguments: tdpf (bool): whether the line is considered in the TDPF calculation wind_speed_m_per_s (float): wind speed at the line in m/s (TDPF) @@ -220,7 +221,7 @@ def create_line_dc( # check if bus exist to attach the line to _check_branch_element(net, "Line_dc", index, from_bus_dc, to_bus_dc, node_name="bus_dc") - + index = _get_index_with_check(net, "line_dc", index) tdpf_columns = ( @@ -274,9 +275,9 @@ def create_line_dc( _set_value_if_not_nan(net, index, alpha, "alpha", "line_dc") _set_value_if_not_nan(net, index, temperature_degree_celsius, "temperature_degree_celsius", "line_dc") # add optional columns for TDPF if parameters passed to kwargs: - _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line_dc", bool_) + _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line_dc") for column, value in tdpf_parameters.items(): - _set_value_if_not_nan(net, index, value, column, "line_dc", float64) + _set_value_if_not_nan(net, index, value, column, "line_dc") _add_branch_geodata(net, geodata, index, "line_dc") @@ -291,10 +292,10 @@ def create_lines( std_type: str | Iterable[str], name: Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, - geodata: Iterable[Iterable[tuple[float, float]]] | None = None, - df: float | Iterable[float] = 1.0, - parallel: int | Iterable[int] = 1, - in_service: bool | Iterable[bool] = True, + geodata: Iterable[Iterable[tuple[float, float]]] | Iterable[tuple[float, float]] | None = None, + df: float | Iterable[float] = get_default_value("line", "df"), + parallel: int | Iterable[int] = get_default_value("line", "parallel"), + in_service: bool | Iterable[bool] = get_default_value("line", "in_service"), max_loading_percent: float | Iterable[float] = nan, **kwargs, ) -> npt.NDArray[Int]: @@ -320,7 +321,7 @@ def create_lines( df: derating factor: maximum current of line in relation to nominal current of line (from 0 to 1) parallel: number of parallel line systems max_loading_percent: maximum current loading (only needed for OPF) - + Keyword Arguments: alpha (float): temperature coefficient of resistance: R(T) = R(T_0) * (1 + alpha * (T - T_0)) temperature_degree_celsius (float): line temperature for which line resistance is adjusted @@ -384,7 +385,7 @@ def create_lines( _add_to_entries_if_not_nan(net, "line", entries, index, "max_loading_percent", max_loading_percent) # add optional columns for TDPF if parameters passed to kwargs: - _add_to_entries_if_not_nan(net, "line", entries, index, "tdpf", kwargs.get("tdpf"), bool_) + _add_to_entries_if_not_nan(net, "line", entries, index, "tdpf", kwargs.get("tdpf")) tdpf_columns = ( "wind_speed_m_per_s", "wind_angle_degree", @@ -399,11 +400,9 @@ def create_lines( ) tdpf_parameters = {c: kwargs.pop(c) for c in tdpf_columns if c in kwargs} for column, value in tdpf_parameters.items(): - _add_to_entries_if_not_nan(net, "line", entries, index, column, value, float64) + _add_to_entries_if_not_nan(net, "line", entries, index, column, value) _set_multiple_entries(net, "line", index, entries=entries) - if "geo" in net.bus.columns: - net.line.loc[net.line.geo == "", "geo"] = None # overwrite _add_multiple_branch_geodata(net, geodata, index) @@ -419,9 +418,9 @@ def create_lines_dc( name: Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, geodata: Iterable[Iterable[tuple[float, float]]] | None = None, - df: float | Iterable[float] = 1.0, - parallel: int | Iterable[int] = 1, - in_service: bool | Iterable[bool] = True, + df: float | Iterable[float] = get_default_value("line", "df"), + parallel: int | Iterable[int] = get_default_value("line", "parallel"), + in_service: bool | Iterable[bool] = get_default_value("line", "in_service"), max_loading_percent: float | Iterable[float] = nan, **kwargs, ) -> npt.NDArray[Int]: @@ -447,7 +446,7 @@ def create_lines_dc( df: derating factor: maximum current of line in relation to nominal current of line (from 0 to 1) parallel: number of parallel line systems max_loading_percent: maximum current loading (only needed for OPF) - + Keyword Arguments: alpha (float): temperature coefficient of resistance: R(T) = R(T_0) * (1 + alpha * (T - T_0)) temperature_degree_celsius (float): line temperature for which line resistance is adjusted @@ -464,7 +463,7 @@ def create_lines_dc( r_theta_kelvin_per_mw (float): thermal resistance of the line (TDPF, only for simplified method) mc_joule_per_m_k (float): specific mass of the conductor multiplied by the specific thermal capacity of the material (TDPF, only for thermal inertia consideration with tdpf_delay_s parameter) - + Returns: The unique ID of the created dc lines @@ -509,7 +508,7 @@ def create_lines_dc( _add_to_entries_if_not_nan(net, "line_dc", entries, index, "max_loading_percent", max_loading_percent) # add optional columns for TDPF if parameters passed to kwargs: - _add_to_entries_if_not_nan(net, "line_dc", entries, index, "tdpf", kwargs.get("tdpf"), bool_) + _add_to_entries_if_not_nan(net, "line_dc", entries, index, "tdpf", kwargs.get("tdpf")) tdpf_columns = ( "wind_speed_m_per_s", "wind_angle_degree", @@ -524,7 +523,7 @@ def create_lines_dc( ) tdpf_parameters = {c: kwargs.pop(c) for c in tdpf_columns if c in kwargs} for column, value in tdpf_parameters.items(): - _add_to_entries_if_not_nan(net, "line_dc", entries, index, column, value, float64) + _add_to_entries_if_not_nan(net, "line_dc", entries, index, column, value) _set_multiple_entries(net, "line_dc", index, entries=entries) @@ -546,17 +545,17 @@ def create_line_from_parameters( index: Int | None = None, type: LineType | None = None, geodata: Iterable[tuple[float, float]] | None = None, - in_service: bool = True, - df: float = 1.0, - parallel: int = 1, - g_us_per_km: float = 0.0, + in_service: bool = get_default_value("line", "in_service"), + df: float = get_default_value("line", "df"), + parallel: int = get_default_value("line", "parallel"), + g_us_per_km: float = get_default_value("line", "g_us_per_km"), max_loading_percent: float = nan, alpha: float = nan, temperature_degree_celsius: float = nan, r0_ohm_per_km: float = nan, x0_ohm_per_km: float = nan, c0_nf_per_km: float = nan, - g0_us_per_km: float = 0, + g0_us_per_km: float = get_default_value("line", "g0_us_per_km"), endtemp_degree: float = nan, **kwargs, ) -> Int: @@ -604,7 +603,7 @@ def create_line_from_parameters( r_theta_kelvin_per_mw: thermal resistance of the line (TDPF, only for simplified method) mc_joule_per_m_k: specific mass of the conductor multiplied by the specific thermal capacity of the material (TDPF, only for thermal inertia consideration with tdpf_delay_s parameter) - + Returns: The unique ID of the created line @@ -657,7 +656,9 @@ def create_line_from_parameters( _set_value_if_not_nan(net, index, r0_ohm_per_km, "r0_ohm_per_km", "line") _set_value_if_not_nan(net, index, x0_ohm_per_km, "x0_ohm_per_km", "line") _set_value_if_not_nan(net, index, c0_nf_per_km, "c0_nf_per_km", "line") - _set_value_if_not_nan(net, index, g0_us_per_km, "g0_us_per_km", "line", default_val=0.0) + _set_value_if_not_nan( + net, index, g0_us_per_km, "g0_us_per_km", "line", default_val=get_default_value("line", "g0_us_per_km") + ) elif not np_all(nan_0_values): logger.warning( "Zero sequence values are given for only some parameters. Please specify " @@ -670,9 +671,9 @@ def create_line_from_parameters( _set_value_if_not_nan(net, index, endtemp_degree, "endtemp_degree", "line") # add optional columns for TDPF if parameters passed to kwargs: - _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line", bool_) + _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line") for column, value in tdpf_parameters.items(): - _set_value_if_not_nan(net, index, value, column, "line", float64) + _set_value_if_not_nan(net, index, value, column, "line") _add_branch_geodata(net, geodata, index) return index @@ -689,13 +690,13 @@ def create_line_dc_from_parameters( index: Int | None = None, type: LineType | None = None, geodata: Iterable[tuple[float, float]] | None = None, - in_service: bool = True, - df: float = 1.0, - parallel: int = 1, + in_service: bool = get_default_value("line_dc", "in_service"), + df: float = get_default_value("line_dc", "df"), + parallel: int = get_default_value("line_dc", "parallel"), max_loading_percent: float = nan, alpha: float = nan, temperature_degree_celsius: float = nan, - g_us_per_km: float = 0.0, + g_us_per_km: float = get_default_value("line_dc", "g_us_per_km"), **kwargs, ) -> Int: """ @@ -792,9 +793,9 @@ def create_line_dc_from_parameters( _set_value_if_not_nan(net, index, temperature_degree_celsius, "temperature_degree_celsius", "line_dc") # add optional columns for TDPF if parameters passed to kwargs: - _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line_dc", bool_) + _set_value_if_not_nan(net, index, kwargs.get("tdpf"), "tdpf", "line_dc") for column, value in tdpf_parameters.items(): - _set_value_if_not_nan(net, index, value, column, "line_dc", float64) + _set_value_if_not_nan(net, index, value, column, "line_dc") _add_branch_geodata(net, geodata, index, "line_dc") @@ -814,10 +815,10 @@ def create_lines_from_parameters( index: Int | Iterable[Int] | None = None, type: LineType | Iterable[str] | None = None, geodata: Iterable[Iterable[tuple[float, float]]] | None = None, - in_service: bool | Iterable[bool] = True, - df: float | Iterable[float] = 1.0, - parallel: int | Iterable[int] = 1, - g_us_per_km: float | Iterable[float] = 0.0, + in_service: bool | Iterable[bool] = get_default_value("line", "in_service"), + df: float | Iterable[float] = get_default_value("line", "df"), + parallel: int | Iterable[int] = get_default_value("line", "parallel"), + g_us_per_km: float | Iterable[float] = get_default_value("line", "g_us_per_km"), max_loading_percent: float | Iterable[float] = nan, alpha: float = nan, temperature_degree_celsius: float = nan, @@ -873,7 +874,7 @@ def create_lines_from_parameters( r_theta_kelvin_per_mw (float): thermal resistance of the line (TDPF, only for simplified method) mc_joule_per_m_k (float): specific mass of the conductor multiplied by the specific thermal capacity of the material (TDPF, only for thermal inertia consideration with tdpf_delay_s parameter) - + Returns: The ID of the created lines @@ -913,7 +914,7 @@ def create_lines_from_parameters( _add_to_entries_if_not_nan(net, "line", entries, index, "alpha", alpha) # add optional columns for TDPF if parameters passed to kwargs: - _add_to_entries_if_not_nan(net, "line", entries, index, "tdpf", kwargs.get("tdpf"), bool_) + _add_to_entries_if_not_nan(net, "line", entries, index, "tdpf", kwargs.get("tdpf")) tdpf_columns = ( "wind_speed_m_per_s", "wind_angle_degree", @@ -928,7 +929,7 @@ def create_lines_from_parameters( ) tdpf_parameters = {c: kwargs.pop(c) for c in tdpf_columns if c in kwargs} for column, value in tdpf_parameters.items(): - _add_to_entries_if_not_nan(net, "line", entries, index, column, value, float64) + _add_to_entries_if_not_nan(net, "line", entries, index, column, value) _set_multiple_entries(net, "line", index, entries=entries) @@ -948,10 +949,10 @@ def create_lines_dc_from_parameters( index: Int | Iterable[Int] | None = None, type: LineType | Iterable[str] | None = None, geodata: Iterable[Iterable[tuple[float, float]]] | None = None, - in_service: bool | Iterable[bool] = True, - df: float | Iterable[float] = 1.0, - parallel: int | Iterable[int] = 1, - g_us_per_km: float | Iterable[float] = 0.0, + in_service: bool | Iterable[bool] = get_default_value("line_dc", "in_service"), + df: float | Iterable[float] = get_default_value("line_dc", "df"), + parallel: int | Iterable[int] = get_default_value("line_dc", "parallel"), + g_us_per_km: float | Iterable[float] = get_default_value("line_dc", "g_us_per_km"), max_loading_percent: float | Iterable[float] = nan, alpha: float = nan, temperature_degree_celsius: float = nan, @@ -982,7 +983,7 @@ def create_lines_dc_from_parameters( max_loading_percent: maximum current loading (only needed for OPF) alpha: temperature coefficient of resistance: R(T) = R(T_0) * (1 + alpha * (T - T_0))) temperature_degree_celsius: line temperature for which line resistance is adjusted - + Keyword Arguments: tdpf (bool): whether the line is considered in the TDPF calculation wind_speed_m_per_s (float): wind speed at the line in m/s (TDPF) @@ -997,7 +998,7 @@ def create_lines_dc_from_parameters( r_theta_kelvin_per_mw (float): thermal resistance of the line (TDPF, only for simplified method) mc_joule_per_m_k (float): specific mass of the conductor multiplied by the specific thermal capacity of the material (TDPF, only for thermal inertia consideration with tdpf_delay_s parameter) - + Return: List of IDs of the created dc lines @@ -1032,7 +1033,7 @@ def create_lines_dc_from_parameters( _add_to_entries_if_not_nan(net, "line_dc", entries, index, "alpha", alpha) # add optional columns for TDPF if parameters passed to kwargs: - _add_to_entries_if_not_nan(net, "line_dc", entries, index, "tdpf", kwargs.get("tdpf"), bool_) + _add_to_entries_if_not_nan(net, "line_dc", entries, index, "tdpf", kwargs.get("tdpf")) tdpf_columns = ( "wind_speed_m_per_s", "wind_angle_degree", @@ -1047,7 +1048,7 @@ def create_lines_dc_from_parameters( ) tdpf_parameters = {c: kwargs.pop(c) for c in tdpf_columns if c in kwargs} for column, value in tdpf_parameters.items(): - _add_to_entries_if_not_nan(net, "line_dc", entries, index, column, value, float64) + _add_to_entries_if_not_nan(net, "line_dc", entries, index, column, value) _set_multiple_entries(net, "line_dc", index, entries=entries) @@ -1072,7 +1073,7 @@ def create_dcline( min_q_to_mvar: float = nan, max_q_from_mvar: float = nan, max_q_to_mvar: float = nan, - in_service: bool = True, + in_service: bool = get_default_value("dcline", "in_service"), **kwargs, ) -> Int: """ @@ -1095,7 +1096,7 @@ def create_dcline( min_q_to_mvar: Minimum reactive power at to bus. Necessary for OPF max_q_from_mvar: Maximum reactive power at from bus. Necessary for OPF max_q_to_mvar: Maximum reactive power at to bus. Necessary for OPF - + Return: ID of the created element diff --git a/pandapower/create/load_create.py b/pandapower/create/load_create.py index 6dd6201dae..b2229a53cb 100644 --- a/pandapower/create/load_create.py +++ b/pandapower/create/load_create.py @@ -8,10 +8,11 @@ import logging from typing import Iterable, Sequence -from numpy import nan, bool_ +from numpy import nan import numpy.typing as npt from pandapower.auxiliary import pandapowerNet +from pandapower.network_structure import get_default_value from pandapower.pp_types import Int, UnderOverExcitedType, WyeDeltaType from pandapower.create._utils import ( _add_to_entries_if_not_nan, @@ -32,22 +33,22 @@ def create_load( net: pandapowerNet, bus: Int, p_mw: float, - q_mvar: float = 0, - const_z_p_percent: float = 0, - const_i_p_percent: float = 0, - const_z_q_percent: float = 0, - const_i_q_percent: float = 0, + q_mvar: float = get_default_value("load", "q_mvar"), + const_z_p_percent: float = get_default_value("load", "const_z_p_percent"), + const_i_p_percent: float = get_default_value("load", "const_i_p_percent"), + const_z_q_percent: float = get_default_value("load", "const_z_q_percent"), + const_i_q_percent: float = get_default_value("load", "const_i_q_percent"), sn_mva: float = nan, name: str | None = None, - scaling: float = 1.0, + scaling: float = get_default_value("load", "scaling"), index: Int | None = None, - in_service: bool = True, + in_service: bool = get_default_value("load", "in_service"), type: WyeDeltaType = "wye", max_p_mw: float = nan, min_p_mw: float = nan, max_q_mvar: float = nan, min_q_mvar: float = nan, - controllable: bool | float = nan, + controllable: bool = get_default_value("load", "controllable"), **kwargs, ) -> Int: """ @@ -113,6 +114,7 @@ def create_load( "sn_mva": sn_mva, "in_service": in_service, "type": type, + "controllable": controllable, **kwargs, } _set_entries(net, "load", index, True, entries=entries) @@ -121,7 +123,6 @@ def create_load( _set_value_if_not_nan(net, index, max_p_mw, "max_p_mw", "load") _set_value_if_not_nan(net, index, min_q_mvar, "min_q_mvar", "load") _set_value_if_not_nan(net, index, max_q_mvar, "max_q_mvar", "load") - _set_value_if_not_nan(net, index, controllable, "controllable", "load", dtype=bool_, default_val=False) return index @@ -130,22 +131,22 @@ def create_loads( net: pandapowerNet, buses: Sequence, p_mw: float | Iterable[float], - q_mvar: float | Iterable[float] = 0, - const_z_p_percent: float | Iterable[float] = 0, - const_i_p_percent: float | Iterable[float] = 0, - const_z_q_percent: float | Iterable[float] = 0, - const_i_q_percent: float | Iterable[float] = 0, + q_mvar: float | Iterable[float] = get_default_value("load", "q_mvar"), + const_z_p_percent: float | Iterable[float] = get_default_value("load", "const_z_p_percent"), + const_i_p_percent: float | Iterable[float] = get_default_value("load", "const_i_p_percent"), + const_z_q_percent: float | Iterable[float] = get_default_value("load", "const_z_q_percent"), + const_i_q_percent: float | Iterable[float] = get_default_value("load", "const_i_q_percent"), sn_mva: float | Iterable[float] = nan, name: Iterable[str] | None = None, - scaling: float | Iterable[float] = 1.0, + scaling: float | Iterable[float] = get_default_value("load", "scaling"), index: Int | Iterable[Int] | None = None, - in_service: bool | Iterable[bool] = True, - type: WyeDeltaType = "wye", + in_service: bool | Iterable[bool] = get_default_value("load", "in_service"), + type: WyeDeltaType = get_default_value("load", "type"), max_p_mw: float | Iterable[float] = nan, min_p_mw: float | Iterable[float] = nan, max_q_mvar: float | Iterable[float] = nan, min_q_mvar: float | Iterable[float] = nan, - controllable: bool | Iterable[bool] | float = nan, + controllable: bool | Iterable[bool] = get_default_value("load", "controllable"), **kwargs, ) -> npt.NDArray[Int]: """ @@ -219,11 +220,16 @@ def create_loads( _add_to_entries_if_not_nan(net, "load", entries, index, "min_q_mvar", min_q_mvar) _add_to_entries_if_not_nan(net, "load", entries, index, "max_q_mvar", max_q_mvar) _add_to_entries_if_not_nan( - net, "load", entries, index, "controllable", controllable, dtype=bool_, default_val=False + net, + "load", + entries, + index, + "controllable", + controllable, + default_val=get_default_value("load", "controllable"), ) - defaults_to_fill = [("controllable", False)] - _set_multiple_entries(net, "load", index, defaults_to_fill=defaults_to_fill, entries=entries) + _set_multiple_entries(net, "load", index, entries=entries) return index @@ -231,21 +237,21 @@ def create_loads( def create_asymmetric_load( net: pandapowerNet, bus: Int, - p_a_mw: float = 0, - p_b_mw: float = 0, - p_c_mw: float = 0, - q_a_mvar: float = 0, - q_b_mvar: float = 0, - q_c_mvar: float = 0, - sn_a_mva: float=nan, - sn_b_mva: float=nan, - sn_c_mva: float=nan, + p_a_mw: float = get_default_value("asymmetric_load", "p_a_mw"), + p_b_mw: float = get_default_value("asymmetric_load", "p_b_mw"), + p_c_mw: float = get_default_value("asymmetric_load", "p_c_mw"), + q_a_mvar: float = get_default_value("asymmetric_load", "q_a_mvar"), + q_b_mvar: float = get_default_value("asymmetric_load", "q_b_mvar"), + q_c_mvar: float = get_default_value("asymmetric_load", "q_c_mvar"), + sn_a_mva: float = nan, + sn_b_mva: float = nan, + sn_c_mva: float = nan, sn_mva: float = nan, name: str | None = None, - scaling: float = 1.0, + scaling: float = get_default_value("asymmetric_load", "scaling"), index: Int | None = None, - in_service: bool = True, - type: WyeDeltaType = "wye", + in_service: bool = get_default_value("asymmetric_load", "in_service"), + type: WyeDeltaType = get_default_value("asymmetric_load", "type"), **kwargs, ) -> Int: """ @@ -399,12 +405,12 @@ def create_load_dc( net: pandapowerNet, bus_dc: Int, p_dc_mw: float, - scaling: float = 1.0, + scaling: float = get_default_value("load_dc", "scaling"), type: str | None = None, index: Int | None = None, name: str | None = None, - in_service: bool = True, - controllable: bool = False, + in_service: bool = get_default_value("load_dc", "in_service"), + controllable: bool = get_default_value("load_dc", "controllable"), **kwargs, ): """ diff --git a/pandapower/create/measurement_create.py b/pandapower/create/measurement_create.py index 82e91c77fb..a9c6c09b84 100644 --- a/pandapower/create/measurement_create.py +++ b/pandapower/create/measurement_create.py @@ -13,6 +13,7 @@ from pandapower.auxiliary import pandapowerNet from pandapower.pp_types import Int, MeasurementElementType, MeasurementType from pandapower.create._utils import _get_index_with_check, _set_entries +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -25,7 +26,7 @@ def create_measurement( std_dev: float, element: int, side: int | Literal["from", "to"] | Literal["hv", "mv", "lv"] | None = None, - check_existing: bool = False, + check_existing: bool = get_default_value("measurement", "check_existing"), index: Int | None = None, name: str | None = None, **kwargs, diff --git a/pandapower/create/motor_create.py b/pandapower/create/motor_create.py index 4606728e14..f3d5f67f6c 100644 --- a/pandapower/create/motor_create.py +++ b/pandapower/create/motor_create.py @@ -11,6 +11,7 @@ from pandapower.auxiliary import pandapowerNet from pandapower.pp_types import Int from pandapower.create._utils import _check_element, _get_index_with_check, _set_entries +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -20,15 +21,15 @@ def create_motor( bus: Int, pn_mech_mw: float, cos_phi: float, - efficiency_percent: float = 100.0, - loading_percent: float = 100.0, + efficiency_percent: float = get_default_value("motor", "efficiency_percent"), + loading_percent: float = get_default_value("motor", "loading_percent"), name: str | None = None, lrc_pu: float = nan, - scaling: float = 1.0, + scaling: float = get_default_value("motor", "scaling"), vn_kv: float = nan, rx: float = nan, index: Int | None = None, - in_service: bool = True, + in_service: bool = get_default_value("motor", "in_service"), cos_phi_n: float = nan, efficiency_n_percent: float = nan, **kwargs, diff --git a/pandapower/create/network_create.py b/pandapower/create/network_create.py index c2d0f9dfca..dcc84996cb 100644 --- a/pandapower/create/network_create.py +++ b/pandapower/create/network_create.py @@ -16,7 +16,7 @@ def create_empty_network( - name: str = "", f_hz: float = 50.0, sn_mva: float = 1, add_stdtypes: bool = True + name: str = "", f_hz: float = 50.0, sn_mva: float = 1, add_stdtypes: bool = True, structure: dict | None = None ) -> pandapowerNet: """ This function initializes the pandapower data structure. @@ -26,6 +26,8 @@ def create_empty_network( name: name for the network sn_mva: reference apparent power for per unit system add_stdtypes: Includes standard types to net + structure: can contain dict from which the network structure is created, when columns that are not relevant + for the loadflow are required Returns: net: pandapower attrdict with empty tables @@ -34,17 +36,16 @@ def create_empty_network( >>> net = create_empty_network() """ - network_structure_dict = get_structure_dict() + if structure is None: + network_structure_dict = get_structure_dict() + else: + network_structure_dict = structure network_structure_dict["name"] = name network_structure_dict["f_hz"] = f_hz network_structure_dict["sn_mva"] = sn_mva net = pandapowerNet(pandapowerNet.create_dataframes(network_structure_dict)) - net._empty_res_load_3ph = net._empty_res_load - net._empty_res_sgen_3ph = net._empty_res_sgen - net._empty_res_storage_3ph = net._empty_res_storage - if add_stdtypes: add_basic_std_types(net) else: diff --git a/pandapower/create/sgen_create.py b/pandapower/create/sgen_create.py index e85fc260f3..934c8e29ce 100644 --- a/pandapower/create/sgen_create.py +++ b/pandapower/create/sgen_create.py @@ -8,7 +8,6 @@ import logging from typing import Iterable, Sequence -import numpy as np import pandas as pd from numpy import nan, bool_ import numpy.typing as npt @@ -25,6 +24,7 @@ _set_multiple_entries, _set_value_if_not_nan, ) +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -33,25 +33,25 @@ def create_sgen( net: pandapowerNet, bus: Int, p_mw: float, - q_mvar: float = 0, + q_mvar: float = get_default_value("sgen", "q_mvar"), sn_mva: float = nan, - name: str | None = None, + name: str = pd.NA, index: Int | None = None, - scaling: float = 1.0, + scaling: float = get_default_value("sgen", "scaling"), type: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("sgen", "in_service"), max_p_mw: float = nan, min_p_mw: float = nan, max_q_mvar: float = nan, min_q_mvar: float = nan, - controllable: bool | None = None, + controllable: bool | None = pd.NA, k: float = nan, rx: float = nan, - id_q_capability_characteristic: int | None = None, - reactive_capability_curve: bool = False, - curve_style=None, - current_source: bool = True, - generator_type: GeneratorType | None = None, + id_q_capability_characteristic: int = pd.NA, + reactive_capability_curve: bool = pd.NA, + curve_style: str = pd.NA, + current_source: bool = get_default_value("sgen", "current_source"), + generator_type: GeneratorType = pd.NA, max_ik_ka: float = nan, kappa: float = nan, lrc_pu: float = nan, @@ -97,11 +97,11 @@ def create_sgen( curve_style: The curve style of the generator represents the relationship between active power (P) and reactive power (Q). It indicates whether the reactive power remains constant as the active power changes or varies dynamically in response to it, e.g. "straightLineYValues" and "constantYValue" generator_type: can be one of - + - "current_source" (full size converter) - "async" (asynchronous generator) - "async_doubly_fed" (doubly fed asynchronous generator, DFIG). - + Represents the type of the static generator in the context of the short-circuit calculations of wind power station units. If None, other short-circuit-related parameters are not set lrc_pu: locked rotor current in relation to the rated generator current. Relevant if the generator_type is @@ -141,23 +141,22 @@ def create_sgen( _set_value_if_not_nan(net, index, max_p_mw, "max_p_mw", "sgen") _set_value_if_not_nan(net, index, min_q_mvar, "min_q_mvar", "sgen") _set_value_if_not_nan(net, index, max_q_mvar, "max_q_mvar", "sgen") - _set_value_if_not_nan(net, index, controllable, "controllable", "sgen", dtype=bool_, default_val=False) - _set_value_if_not_nan( - net, index, id_q_capability_characteristic, "id_q_capability_characteristic", "sgen", dtype="Int64" + net, index, controllable, "controllable", "sgen", default_val=get_default_value("sgen", "controllable") ) - _set_value_if_not_nan(net, index, reactive_capability_curve, "reactive_capability_curve", "sgen", dtype=bool_) + _set_value_if_not_nan(net, index, id_q_capability_characteristic, "id_q_capability_characteristic", "sgen") + + _set_value_if_not_nan(net, index, reactive_capability_curve, "reactive_capability_curve", "sgen") - _set_value_if_not_nan(net, index, curve_style, "curve_style", "sgen", dtype=object, default_val=None) + _set_value_if_not_nan(net, index, curve_style, "curve_style", "sgen") _set_value_if_not_nan(net, index, rx, "rx", "sgen") # rx is always required - if np.isfinite(kappa): - _set_value_if_not_nan(net, index, kappa, "kappa", "sgen") + _set_value_if_not_nan(net, index, kappa, "kappa", "sgen", default_val=nan) _set_value_if_not_nan( - net, index, generator_type, "generator_type", "sgen", dtype="str", default_val="current_source" + net, index, generator_type, "generator_type", "sgen", default_val=get_default_value("sgen", "generator_type") ) - if generator_type == "current_source" or generator_type is None: + if pd.isna(generator_type) or generator_type == "current_source": _set_value_if_not_nan(net, index, k, "k", "sgen") elif generator_type == "async": _set_value_if_not_nan(net, index, lrc_pu, "lrc_pu", "sgen") @@ -176,25 +175,25 @@ def create_sgens( net: pandapowerNet, buses: Sequence, p_mw: float | Iterable[float], - q_mvar: float | Iterable[float] = 0, + q_mvar: float | Iterable[float] = get_default_value("sgen", "q_mvar"), sn_mva: float | Iterable[float] = nan, - name: Iterable[str] | None = None, + name: Iterable[str] = pd.NA, index: Int | Iterable[Int] | None = None, - scaling: float | Iterable[float] = 1.0, - type: WyeDeltaType = "wye", - in_service: bool | Iterable[bool] = True, + scaling: float | Iterable[float] = get_default_value("sgen", "scaling"), + type: WyeDeltaType = get_default_value("sgen", "type"), + in_service: bool | Iterable[bool] = get_default_value("sgen", "in_service"), max_p_mw: float | Iterable[float] = nan, min_p_mw: float | Iterable[float] = nan, max_q_mvar: float | Iterable[float] = nan, min_q_mvar: float | Iterable[float] = nan, - controllable: bool | Iterable[bool] | None = None, + controllable: bool | Iterable[bool] | None = pd.NA, k: float | Iterable[float] = nan, rx: float = nan, - id_q_capability_characteristic: Int | Iterable[Int] | None = None, - reactive_capability_curve: bool | Iterable[bool] = False, - curve_style: str | Iterable[str] | None = None, - current_source: bool | Iterable[bool] = True, - generator_type: GeneratorType = "current_source", + id_q_capability_characteristic: Int | Iterable[Int] = pd.NA, + reactive_capability_curve: bool | Iterable[bool] = pd.NA, + curve_style: str | Iterable[str] = pd.NA, + current_source: bool | Iterable[bool] = get_default_value("sgen", "current_source"), + generator_type: GeneratorType = get_default_value("sgen", "generator_type"), max_ik_ka: float = nan, kappa: float = nan, lrc_pu: float = nan, @@ -268,21 +267,34 @@ def create_sgens( _add_to_entries_if_not_nan(net, "sgen", entries, index, "min_q_mvar", min_q_mvar) _add_to_entries_if_not_nan(net, "sgen", entries, index, "max_q_mvar", max_q_mvar) _add_to_entries_if_not_nan( - net, "sgen", entries, index, "controllable", controllable, dtype=bool_, default_val=False + net, + "sgen", + entries, + index, + "controllable", + controllable, + dtype=bool_, + default_val=get_default_value("sgen", "controllable"), ) _add_to_entries_if_not_nan(net, "sgen", entries, index, "rx", rx) # rx is always required - if np.isfinite(kappa): - _add_to_entries_if_not_nan( - net, "sgen", entries, index, "kappa", kappa - ) # is used for Type C also as a max. current limit _add_to_entries_if_not_nan( - net, "sgen", entries, index, "generator_type", generator_type, dtype="str", default_val="current_source" + net, "sgen", entries, index, "kappa", kappa, default_val=nan + ) # is used for Type C also as a max. current limit + _add_to_entries_if_not_nan( + net, + "sgen", + entries, + index, + "generator_type", + generator_type, + dtype="str", + default_val=get_default_value("sgen", "generator_type"), ) gen_types = ["current_source", "async", "async_doubly_fed"] gen_type_match = pd.concat([entries["generator_type"] == match for match in gen_types], axis=1, keys=gen_types) # type: ignore[call-overload] _add_to_entries_if_not_nan( - net, "sgen", entries, index, "id_q_capability_characteristic", id_q_capability_characteristic, dtype="Int64" + net, "sgen", entries, index, "id_q_capability_characteristic", id_q_capability_characteristic ) if gen_type_match["current_source"].any(): @@ -296,9 +308,13 @@ def create_sgens( f"unknown sgen generator_type '{generator_type}'! " f"Must be one of: None, 'current_source', 'async', 'async_doubly_fed'" ) - - defaults_to_fill = [("controllable", False), ("reactive_capability_curve", False), ("curve_style", None)] - _set_multiple_entries(net, "sgen", index, defaults_to_fill=defaults_to_fill, entries=entries) + _set_multiple_entries( + net, + "sgen", + index, + entries=entries, + defaults_to_fill=[("controllable", get_default_value("sgen", "controllable"))], + ) return index @@ -311,21 +327,21 @@ def create_sgens( def create_asymmetric_sgen( net: pandapowerNet, bus: Int, - p_a_mw: float = 0, - p_b_mw: float = 0, - p_c_mw: float = 0, - q_a_mvar: float = 0, - q_b_mvar: float = 0, - q_c_mvar: float = 0, + p_a_mw: float = get_default_value("asymmetric_sgen", "p_a_mw"), + p_b_mw: float = get_default_value("asymmetric_sgen", "p_b_mw"), + p_c_mw: float = get_default_value("asymmetric_sgen", "p_c_mw"), + q_a_mvar: float = get_default_value("asymmetric_sgen", "q_a_mvar"), + q_b_mvar: float = get_default_value("asymmetric_sgen", "q_b_mvar"), + q_c_mvar: float = get_default_value("asymmetric_sgen", "q_c_mvar"), sn_a_mva: float = nan, sn_b_mva: float = nan, sn_c_mva: float = nan, sn_mva: float = nan, name: str | None = None, index: Int | None = None, - scaling: float = 1.0, - type: WyeDeltaType = "wye", - in_service: bool = True, + scaling: float = get_default_value("asymmetric_sgen", "scaling"), + type: WyeDeltaType = get_default_value("asymmetric_sgen", "type"), + in_service: bool = get_default_value("asymmetric_sgen", "in_service"), **kwargs, ) -> Int: """ @@ -405,7 +421,7 @@ def create_sgen_from_cosphi( # no index ? sn_mva: rated power of the generator cos_phi: power factor cos_phi mode: - + - "underexcited" (Q absorption, decreases voltage) - "overexcited" (Q injection, increases voltage) diff --git a/pandapower/create/shunt_create.py b/pandapower/create/shunt_create.py index e52034e560..660deaf6e5 100644 --- a/pandapower/create/shunt_create.py +++ b/pandapower/create/shunt_create.py @@ -22,6 +22,7 @@ _set_multiple_entries, _set_value_if_not_nan, ) +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -30,14 +31,14 @@ def create_shunt( net: pandapowerNet, bus: Int, q_mvar: float, - p_mw: float = 0.0, + p_mw: float = get_default_value("shunt", "p_mw"), vn_kv: float | None = None, - step: int = 1, - max_step: int = 1, + step: int = get_default_value("shunt", "step"), + max_step: int = get_default_value("shunt", "max_step"), name: str | None = None, - step_dependency_table: bool = False, + step_dependency_table: bool = get_default_value("shunt", "step_dependency_table"), id_characteristic_table: int | None = None, - in_service: bool = True, + in_service: bool = get_default_value("shunt", "in_service"), index: Int | None = None, **kwargs, ) -> Int: @@ -94,7 +95,7 @@ def create_shunt( } _set_entries(net, "shunt", index, entries=entries) - _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "shunt", dtype="Int64") + _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "shunt") return index @@ -103,14 +104,14 @@ def create_shunts( net: pandapowerNet, buses: Sequence, q_mvar: float | Iterable[float], - p_mw: float | Iterable[float] = 0.0, + p_mw: float | Iterable[float] = get_default_value("shunt", "p_mw"), vn_kv: float | Iterable[float] | None = None, - step: int | Iterable[int] = 1, - max_step: int | Iterable[int] = 1, + step: int | Iterable[int] = get_default_value("shunt", "step"), + max_step: int | Iterable[int] = get_default_value("shunt", "max_step"), name: Iterable[str] | None = None, - step_dependency_table: bool | Iterable[bool] = False, + step_dependency_table: bool | Iterable[bool] = get_default_value("shunt", "step_dependency_table"), id_characteristic_table: int | Iterable[int] | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("shunt", "in_service"), index=None, **kwargs, ) -> npt.NDArray[np.array]: @@ -179,7 +180,7 @@ def create_shunt_as_capacitor(net: pandapowerNet, bus: Int, q_mvar: float, loss_ bus: index of the bus the shunt is connected to q_mvar: reactive power of the capacitor bank at rated voltage loss_factor: loss factor tan(delta) of the capacitor bank - + Keyword Arguments: any args for create_shunt, keyword arguments are passed to the create_shunt function @@ -199,11 +200,11 @@ def create_svc( set_vm_pu: float, thyristor_firing_angle_degree: float, name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("svc", "controllable"), + in_service: bool = get_default_value("svc", "in_service"), index: Int | None = None, - min_angle_degree: float = 90, - max_angle_degree: float = 180, + min_angle_degree: float = get_default_value("svc", "min_angle_degree"), + max_angle_degree: float = get_default_value("svc", "max_angle_degree"), **kwargs, ) -> Int: """ @@ -262,12 +263,12 @@ def create_ssc( bus: Int, r_ohm: float, x_ohm: float, - set_vm_pu: float = 1.0, - vm_internal_pu: float = 1.0, - va_internal_degree: float = 0.0, + set_vm_pu: float = get_default_value("ssc", "set_vm_pu"), + vm_internal_pu: float = get_default_value("ssc", "vm_internal_pu"), + va_internal_degree: float = get_default_value("ssc", "va_internal_degree"), name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("ssc", "controllable"), + in_service: bool = get_default_value("ssc", "in_service"), index: Int | None = None, **kwargs, ) -> Int: @@ -327,14 +328,14 @@ def create_vsc_stacked( r_ohm: float, x_ohm: float, r_dc_ohm: float, - pl_dc_mw: float = 0.0, - control_mode_ac: str = "vm_pu", - control_value_ac: float = 1.0, - control_mode_dc: str = "p_mw", - control_value_dc: float = 0.0, + pl_dc_mw: float = get_default_value("vsc_stacked", "pl_dc_mw"), + control_mode_ac: str = get_default_value("vsc_stacked", "control_mode_ac"), + control_value_ac: float = get_default_value("vsc_stacked", "control_value_ac"), + control_mode_dc: str = get_default_value("vsc_stacked", "control_mode_dc"), + control_value_dc: float = get_default_value("vsc_stacked", "control_value_dc"), name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("vsc_stacked", "controllable"), + in_service: bool = get_default_value("vsc_stacked", "in_service"), index: Int | None = None, **kwargs, ) -> Int: @@ -404,13 +405,13 @@ def create_vsc_bipolar( r_ohm: float, x_ohm: float, r_dc_ohm: float, - pl_dc_mw: float = 0.0, - control_mode: str = "Vac_phi", - control_value_1: float = 1.0, - control_value_2: float = 0.0, + pl_dc_mw: float = get_default_value("vsc_bipolar", "pl_dc_mw"), + control_mode: str = get_default_value("vsc_bipolar", "control_mode"), + control_value_1: float = get_default_value("vsc_bipolar", "control_value_1"), + control_value_2: float = get_default_value("vsc_bipolar", "control_value_2"), name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("vsc_bipolar", "controllable"), + in_service: bool = get_default_value("vsc_bipolar", "in_service"), index: Int | None = None, **kwargs, ) -> Int: @@ -477,14 +478,14 @@ def create_vsc( r_ohm: float, x_ohm: float, r_dc_ohm: float, - pl_dc_mw: float = 0.0, - control_mode_ac: Literal["vm_pu", "q_mvar", "slack"] = "vm_pu", - control_value_ac: float = 1.0, - control_mode_dc: Literal["vm_pu", "p_mw"] = "p_mw", - control_value_dc: float = 0.0, + pl_dc_mw: float = get_default_value("vsc", "pl_dc_mw"), + control_mode_ac: Literal["vm_pu", "q_mvar", "slack"] = get_default_value("vsc", "control_mode_ac"), + control_value_ac: float = get_default_value("vsc", "control_value_ac"), + control_mode_dc: Literal["vm_pu", "p_mw"] = get_default_value("vsc", "control_mode_dc"), + control_value_dc: float = get_default_value("vsc", "control_value_dc"), name: str | None = None, - controllable: bool = True, - in_service: bool = True, + controllable: bool = get_default_value("vsc", "controllable"), + in_service: bool = get_default_value("vsc", "in_service"), index: Int | None = None, ref_bus=None, **kwargs, diff --git a/pandapower/create/source_create.py b/pandapower/create/source_create.py index 037bf3b2ae..32e14b707c 100644 --- a/pandapower/create/source_create.py +++ b/pandapower/create/source_create.py @@ -10,6 +10,7 @@ from pandapower.auxiliary import pandapowerNet from pandapower.pp_types import Int from pandapower.create._utils import _check_element, _get_index_with_check, _set_entries +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -17,16 +18,16 @@ def create_source_dc( net: pandapowerNet, bus_dc: Int, - vm_pu: float = 1.0, + vm_pu: float = get_default_value("source_dc", "vm_pu"), index: Int | None = None, name: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("source_dc", "in_service"), type: str | None = None, **kwargs, ): """ Creates a dc voltage source in a dc grid with an adjustable set point - + Parameters: net: The pandapower network in which the element is created bus_dc: index of the bus the shunt is connected to diff --git a/pandapower/create/storage_create.py b/pandapower/create/storage_create.py index 03141b1b85..605b0901d4 100644 --- a/pandapower/create/storage_create.py +++ b/pandapower/create/storage_create.py @@ -23,6 +23,7 @@ _set_multiple_entries, _set_value_if_not_nan, ) +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -32,15 +33,15 @@ def create_storage( bus: Int, p_mw: float, max_e_mwh: float, - q_mvar: float = 0, + q_mvar: float = get_default_value("storage", "q_mvar"), sn_mva: float = nan, soc_percent: float = nan, - min_e_mwh: float = 0.0, + min_e_mwh: float = get_default_value("storage", "min_e_mwh"), name: str | None = None, index: Int | None = None, - scaling: float = 1.0, + scaling: float = get_default_value("storage", "scaling"), type: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("storage", "in_service"), max_p_mw: float = nan, min_p_mw: float = nan, max_q_mvar: float = nan, @@ -117,7 +118,9 @@ def create_storage( _set_value_if_not_nan(net, index, max_p_mw, "max_p_mw", "storage") _set_value_if_not_nan(net, index, min_q_mvar, "min_q_mvar", "storage") _set_value_if_not_nan(net, index, max_q_mvar, "max_q_mvar", "storage") - _set_value_if_not_nan(net, index, controllable, "controllable", "storage", dtype=bool_, default_val=False) + _set_value_if_not_nan( + net, index, controllable, "controllable", "storage", default_val=get_default_value("storage", "controllable") + ) return index @@ -127,15 +130,15 @@ def create_storages( buses: Sequence, p_mw: float | Iterable[float], max_e_mwh: float | Iterable[float], - q_mvar: float | Iterable[float] = 0, + q_mvar: float | Iterable[float] = get_default_value("storage", "q_mvar"), sn_mva: float | Iterable[float] = nan, soc_percent: float | Iterable[float] = nan, - min_e_mwh: float | Iterable[float] = 0.0, + min_e_mwh: float | Iterable[float] = get_default_value("storage", "min_e_mwh"), name: Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, - scaling: float | Iterable[float] = 1.0, + scaling: float | Iterable[float] = get_default_value("storage", "scaling"), type: str | Iterable[str] | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("storage", "in_service"), max_p_mw: float | Iterable[float] = nan, min_p_mw: float | Iterable[float] = nan, max_q_mvar: float | Iterable[float] = nan, @@ -209,11 +212,14 @@ def create_storages( _add_to_entries_if_not_nan(net, "storage", entries, index, "max_p_mw", max_p_mw) _add_to_entries_if_not_nan(net, "storage", entries, index, "min_q_mvar", min_q_mvar) _add_to_entries_if_not_nan(net, "storage", entries, index, "max_q_mvar", max_q_mvar) - _add_to_entries_if_not_nan( - net, "storage", entries, index, "controllable", controllable, dtype=bool_, default_val=False + _add_to_entries_if_not_nan(net, "storage", entries, index, "controllable", controllable) + + _set_multiple_entries( + net, + "storage", + index, + defaults_to_fill=[("controllable", get_default_value("storage", "controllable"))], + entries=entries, ) - defaults_to_fill = [("controllable", False)] - - _set_multiple_entries(net, "storage", index, defaults_to_fill=defaults_to_fill, entries=entries) return index diff --git a/pandapower/create/switch_create.py b/pandapower/create/switch_create.py index 7927c2cc67..6771367f81 100644 --- a/pandapower/create/switch_create.py +++ b/pandapower/create/switch_create.py @@ -21,6 +21,7 @@ _set_entries, _set_multiple_entries, ) +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -30,11 +31,11 @@ def create_switch( bus: Int, element: Int, et: SwitchElementType, - closed: bool = True, + closed: bool = get_default_value("switch", "closed"), type: SwitchType | None = None, name: str | None = None, index: Int | None = None, - z_ohm: float = 0, + z_ohm: float = get_default_value("switch", "z_ohm"), in_ka: float = nan, **kwargs, ) -> Int: @@ -55,24 +56,24 @@ def create_switch( bus: The bus that the switch is connected to element: index of the element et: element type - + - "l" = switch between bus and line - "t" = switch between bus and transformer - "t3" = switch between bus and transformer3w - "b" = switch between two buses - + closed: switch position: - + - False = open - True = closed - + type: indicates the type of switch - + - "LS" = Load Switch - "CB" = Circuit Breaker - "LBS" = Load Break Switch - "DS" = Disconnecting Switch - + z_ohm: indicates the resistance of the switch, which has effect only on bus-bus switches, if sets to 0, the buses will be fused like before, if larger than 0 a branch will be created for the switch which has also effects on the bus mapping @@ -139,11 +140,11 @@ def create_switches( buses: Sequence, elements: Sequence, et: SwitchElementType | Sequence[str], - closed: bool | Iterable[bool] = True, + closed: bool | Iterable[bool] = get_default_value("switch", "closed"), type: SwitchType | None = None, name: Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, - z_ohm: float = 0, + z_ohm: float = get_default_value("switch", "z_ohm"), in_ka: float = nan, **kwargs, ) -> Int: @@ -164,24 +165,24 @@ def create_switches( buses: The bus that the switch is connected to element: index of the element et: element type - + - "l" = switch between bus and line - "t" = switch between bus and transformer - "t3" = switch between bus and transformer3w - "b" = switch between two buses - + closed: switch position - + - False = open - True = closed - + type: indicates the type of switch - + - "LS" = Load Switch - "CB" = Circuit Breaker - "LBS" = Load Break Switch or - "DS" = Disconnecting Switch - + z_ohm: indicates the resistance of the switch, which has effect only on bus-bus switches, if sets to 0, the buses will be fused like before, if larger than 0 a branch will be created for the switch which has also effects on the bus mapping diff --git a/pandapower/create/trafo_create.py b/pandapower/create/trafo_create.py index 21ab2b1fbf..87dd1b50b0 100644 --- a/pandapower/create/trafo_create.py +++ b/pandapower/create/trafo_create.py @@ -27,6 +27,7 @@ _set_multiple_entries, _set_value_if_not_nan, ) +from pandapower.network_structure import get_structure_dict, get_default_value logger = logging.getLogger(__name__) @@ -38,16 +39,15 @@ def create_transformer( std_type: str, name: str | None = None, tap_pos: int | float = nan, - in_service: bool = True, + in_service: bool = get_default_value("trafo", "in_service"), index: Int | None = None, max_loading_percent: float = nan, - parallel: int = 1, - df: float = 1.0, + parallel: int = get_default_value("trafo", "parallel"), + df: float = get_default_value("trafo", "df"), tap_changer_type: str | None = None, - tap_dependency_table: bool = False, + tap_dependency_table: bool = pd.NA, id_characteristic_table: int | None = None, pt_percent: float = nan, - oltc: bool = False, xn_ohm: float = nan, tap2_pos: int | float = nan, **kwargs, @@ -61,9 +61,9 @@ def create_transformer( hv_bus: the bus on the high-voltage side on which the transformer will be connected to lv_bus: the bus on the low-voltage side on which the transformer will be connected to std_type: the used standard type from the standard type library - + **Zero sequence parameters** (added through std_type for three-phase load flow): - + - vk0_percent (float): zero sequence relative short-circuit voltage - vkr0_percent (float): real part of zero sequence relative short-circuit voltage - mag0_percent (float): ratio between magnetizing and short circuit impedance (zero sequence) as a percent @@ -180,13 +180,10 @@ def create_transformer( ) _set_value_if_not_nan(net, index, max_loading_percent, "max_loading_percent", "trafo") - _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo", dtype="Int64") - _set_value_if_not_nan( - net, index, tap_dependency_table, "tap_dependency_table", "trafo", dtype=bool_, default_val=False - ) - _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo", dtype=object, default_val=None) + _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo") + _set_value_if_not_nan(net, index, tap_dependency_table, "tap_dependency_table", "trafo") + _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo", default_val=None) _set_value_if_not_nan(net, index, pt_percent, "pt_percent", "trafo") - _set_value_if_not_nan(net, index, oltc, "oltc", "trafo", dtype=bool_, default_val=False) _set_value_if_not_nan(net, index, xn_ohm, "xn_ohm", "trafo") return index @@ -199,16 +196,16 @@ def create_transformers( std_type: str, name: Iterable[str] | None = None, tap_pos: int | Iterable[int] | float = nan, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("trafo", "in_service"), index: Int | Iterable[Int] | None = None, max_loading_percent: float | Iterable[float] = nan, - parallel: int | Iterable[int] = 1, - df: float | Iterable[float] = 1.0, + parallel: int | Iterable[int] = get_default_value("trafo", "parallel"), + df: float | Iterable[float] = get_default_value("trafo", "df"), tap_changer_type: TapChangerWithTabularType | Iterable[str] | None = None, - tap_dependency_table: bool | Iterable[bool] = False, + tap_dependency_table: bool | Iterable[bool] = pd.NA, id_characteristic_table: int | Iterable[int] | None = None, pt_percent: float | Iterable[float] = nan, - oltc: bool | Iterable[bool] = False, + # oltc: bool | Iterable[bool] = False, xn_ohm: float | Iterable[float] = nan, tap2_pos: int | Iterable[int] | float = nan, **kwargs, @@ -217,7 +214,7 @@ def create_transformers( Creates several two-winding transformers in table net.trafo. Additional parameters passed will be added to the transformers dataframe. If keywords are passed that are present in the std_type they will override any setting from the standard type. - + Parameters: net: the pandapower network to which the transformers should be added Sequence hv_buses: a Sequence of bus ids that are the high voltage buses for the transformers @@ -256,18 +253,38 @@ def create_transformers( if not all(param in std_params for param in required_params): raise ValueError(f"std_type is missing a required value. Required values: {', '.join(required_params)}") params_from_std_type = ( - "i0_percent", "vk0_percent", "vkr0_percent", "mag0_percent", "mag0_rx", "si0_hv_partial", "vector_group", - *required_params + "i0_percent", + "vk0_percent", + "vkr0_percent", + "mag0_percent", + "mag0_rx", + "si0_hv_partial", + "vector_group", + *required_params, ) params = {param: std_params[param] for param in params_from_std_type if param in std_params} params.update(kwargs) return create_transformers_from_parameters( - net=net, hv_buses=hv_buses, lv_buses=lv_buses, name=name, tap_pos=tap_pos, in_service=in_service, index=index, - max_loading_percent=max_loading_percent, parallel=parallel, df=df, tap_changer_type=tap_changer_type, - tap_dependency_table=tap_dependency_table, id_characteristic_table=id_characteristic_table, - pt_percent=pt_percent, oltc=oltc, xn_ohm=xn_ohm, tap2_pos=tap2_pos, std_type=std_type, - **params + net=net, + hv_buses=hv_buses, + lv_buses=lv_buses, + name=name, + tap_pos=tap_pos, + in_service=in_service, + index=index, + max_loading_percent=max_loading_percent, + parallel=parallel, + df=df, + tap_changer_type=tap_changer_type, + tap_dependency_table=tap_dependency_table, + id_characteristic_table=id_characteristic_table, + pt_percent=pt_percent, + # oltc=oltc, + xn_ohm=xn_ohm, + tap2_pos=tap2_pos, + std_type=std_type, + **params, ) @@ -282,7 +299,7 @@ def create_transformer_from_parameters( vk_percent: float, pfe_kw: float, i0_percent: float, - shift_degree: float = 0, + shift_degree: float = get_default_value("trafo", "shift_degree"), tap_side: HVLVType | None = None, tap_neutral: int | float = nan, tap_max: int | float = nan, @@ -292,21 +309,21 @@ def create_transformer_from_parameters( tap_pos: int | float = nan, tap_changer_type: TapChangerWithTabularType | None = None, id_characteristic_table: int | None = None, - in_service: bool = True, + in_service: bool = get_default_value("trafo", "in_service"), name: str | None = None, vector_group: str | None = None, index: Int | None = None, max_loading_percent: float = nan, - parallel: int = 1, - df: float = 1.0, + parallel: int = get_default_value("trafo", "parallel"), + df: float = get_default_value("trafo", "df"), vk0_percent: float = nan, vkr0_percent: float = nan, mag0_percent: float = nan, mag0_rx: float = nan, si0_hv_partial: float = nan, pt_percent: float = nan, - oltc: bool = False, - tap_dependency_table: bool = False, + # oltc: bool = False, + tap_dependency_table: bool = pd.NA, xn_ohm: float = nan, tap2_side: HVLVType | None = None, tap2_neutral: int | float = nan, @@ -376,13 +393,13 @@ def create_transformer_from_parameters( tap2_step_percent: second tap step size for voltage magnitude in percent tap2_step_degree: second tap step size for voltage angle in degree* tap2_changer_type: specifies the tap changer type ("Ratio", "Symmetrical", "Ideal", None: no tap changer)* - + \\* only considered in load flow if calculate_voltage_angles = True - + Keyword Arguments: leakage_resistance_ratio_hv: ratio of transformer short-circuit resistance on HV side (default 0.5) leakage_reactance_ratio_hv: ratio of transformer short-circuit reactance on HV side (default 0.5) - + Returns: **index** (int) - the unique ID of the created transformer @@ -412,7 +429,7 @@ def create_transformer_from_parameters( "hv_bus": hv_bus, "lv_bus": lv_bus, "in_service": in_service, - "std_type": None, + "std_type": pd.NA, "sn_mva": sn_mva, "vn_hv_kv": vn_hv_kv, "vn_lv_kv": vn_lv_kv, @@ -436,8 +453,6 @@ def create_transformer_from_parameters( entries["tap_pos"] = entries["tap_neutral"] else: entries["tap_pos"] = tap_pos - if type(tap_pos) is float: - net.trafo.tap_pos = net.trafo.tap_pos.astype(float) for key in ["tap_dependent_impedance", "vk_percent_characteristic", "vkr_percent_characteristic"]: if key in kwargs: @@ -455,22 +470,18 @@ def create_transformer_from_parameters( entries.update(kwargs) _set_entries(net, "trafo", index, entries=entries) - _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo", dtype="Int64") - _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo", dtype=object, default_val=None) - _set_value_if_not_nan( - net, index, tap_dependency_table, "tap_dependency_table", "trafo", dtype=bool_, default_val=False - ) + _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo") + _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo", default_val=None) + _set_value_if_not_nan(net, index, tap_dependency_table, "tap_dependency_table", "trafo") - _set_value_if_not_nan(net, index, tap2_side, "tap2_side", "trafo", dtype=str) - _set_value_if_not_nan(net, index, tap2_neutral, "tap2_neutral", "trafo", dtype=float64) - _set_value_if_not_nan(net, index, tap2_min, "tap2_min", "trafo", dtype=float64) - _set_value_if_not_nan(net, index, tap2_max, "tap2_max", "trafo", dtype=float64) - _set_value_if_not_nan(net, index, tap2_step_percent, "tap2_step_percent", "trafo", dtype=float64) - _set_value_if_not_nan(net, index, tap2_step_degree, "tap2_step_degree", "trafo", dtype=float64) - _set_value_if_not_nan( - net, index, tap2_pos if pd.notnull(tap2_pos) else tap2_neutral, "tap2_pos", "trafo", dtype=float64 - ) - _set_value_if_not_nan(net, index, tap2_changer_type, "tap2_changer_type", "trafo", dtype=object) + _set_value_if_not_nan(net, index, tap2_side, "tap2_side", "trafo") + _set_value_if_not_nan(net, index, tap2_neutral, "tap2_neutral", "trafo") + _set_value_if_not_nan(net, index, tap2_min, "tap2_min", "trafo") + _set_value_if_not_nan(net, index, tap2_max, "tap2_max", "trafo") + _set_value_if_not_nan(net, index, tap2_step_percent, "tap2_step_percent", "trafo") + _set_value_if_not_nan(net, index, tap2_step_degree, "tap2_step_degree", "trafo") + _set_value_if_not_nan(net, index, tap2_pos if pd.notnull(tap2_pos) else tap2_neutral, "tap2_pos", "trafo") + _set_value_if_not_nan(net, index, tap2_changer_type, "tap2_changer_type", "trafo") if any(key in kwargs for key in ["tap_phase_shifter", "tap2_phase_shifter"]): convert_trafo_pst_logic(net) @@ -494,10 +505,10 @@ def create_transformer_from_parameters( _set_value_if_not_nan(net, index, mag0_percent, "mag0_percent", "trafo") _set_value_if_not_nan(net, index, mag0_rx, "mag0_rx", "trafo") _set_value_if_not_nan(net, index, si0_hv_partial, "si0_hv_partial", "trafo") - _set_value_if_not_nan(net, index, vector_group, "vector_group", "trafo", dtype=str) + _set_value_if_not_nan(net, index, vector_group, "vector_group", "trafo") _set_value_if_not_nan(net, index, max_loading_percent, "max_loading_percent", "trafo") _set_value_if_not_nan(net, index, pt_percent, "pt_percent", "trafo") - _set_value_if_not_nan(net, index, oltc, "oltc", "trafo", dtype=bool_, default_val=False) + # _set_value_if_not_nan(net, index, oltc, "oltc", "trafo", default_val=False) _set_value_if_not_nan(net, index, xn_ohm, "xn_ohm", "trafo") return index @@ -514,7 +525,7 @@ def create_transformers_from_parameters( # index missing ? vk_percent: float | Iterable[float], pfe_kw: float | Iterable[float], i0_percent: float | Iterable[float], - shift_degree: float | Iterable[float] = 0, + shift_degree: float | Iterable[float] = get_default_value("trafo", "shift_degree"), tap_side: HVLVType | Iterable[str] | None = None, tap_neutral: int | Iterable[int] | float = nan, tap_max: int | Iterable[int] | float = nan, @@ -524,21 +535,21 @@ def create_transformers_from_parameters( # index missing ? tap_pos: int | Iterable[int] | float = nan, tap_changer_type: TapChangerWithTabularType | Iterable[str] | None = None, id_characteristic_table: int | Iterable[int] | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("trafo", "in_service"), name: Iterable[str] | None = None, vector_group: str | Iterable[str] | None = None, index: Int | Iterable[Int] | None = None, max_loading_percent: float | Iterable[float] = nan, - parallel: int | Iterable[int] = 1, - df: float | Iterable[float] = 1.0, + parallel: int | Iterable[int] = get_default_value("trafo", "parallel"), + df: float | Iterable[float] = get_default_value("trafo", "df"), vk0_percent: float | Iterable[float] = nan, vkr0_percent: float | Iterable[float] = nan, mag0_percent: float | Iterable[float] = nan, mag0_rx: float | Iterable[float] = nan, si0_hv_partial: float | Iterable[float] = nan, pt_percent: float | Iterable[float] = nan, - oltc: bool | Iterable[bool] = False, - tap_dependency_table: bool | Iterable[bool] = False, + # oltc: bool | Iterable[bool] = False, + tap_dependency_table: bool | Iterable[bool] = pd.NA, xn_ohm: float | Iterable[float] = nan, tap2_side: HVLVType | Iterable[str] | None = None, tap2_neutral: int | Iterable[int] | float = nan, @@ -634,7 +645,7 @@ def create_transformers_from_parameters( # index missing ? "hv_bus": hv_buses, "lv_bus": lv_buses, "in_service": array(in_service).astype(bool_), - "std_type": None, + "std_type": pd.NA, "sn_mva": sn_mva, "vn_hv_kv": vn_hv_kv, "vn_lv_kv": vn_lv_kv, @@ -643,13 +654,15 @@ def create_transformers_from_parameters( # index missing ? "pfe_kw": pfe_kw, "i0_percent": i0_percent, "tap_neutral": tp_neutral, - "tap_max": tap_max, - "tap_min": tap_min, + "tap_max": array(tap_max).astype(get_structure_dict(required_only=False)["trafo"]["tap_max"]), + "tap_min": array(tap_min).astype(get_structure_dict(required_only=False)["trafo"]["tap_min"]), "shift_degree": shift_degree, "tap_pos": tp_pos, "tap_side": tap_side, "tap_step_percent": tap_step_percent, - "tap_step_degree": tap_step_degree, + "tap_step_degree": array(tap_step_degree).astype( + get_structure_dict(required_only=False)["trafo"]["tap_step_degree"] + ), "tap_changer_type": tap_changer_type, "parallel": parallel, "df": df, @@ -657,30 +670,26 @@ def create_transformers_from_parameters( # index missing ? **kwargs, } - _add_to_entries_if_not_nan( - net, "trafo", entries, index, "id_characteristic_table", id_characteristic_table, dtype="Int64" - ) + _add_to_entries_if_not_nan(net, "trafo", entries, index, "id_characteristic_table", id_characteristic_table) _add_to_entries_if_not_nan(net, "trafo", entries, index, "vk0_percent", vk0_percent) _add_to_entries_if_not_nan(net, "trafo", entries, index, "vkr0_percent", vkr0_percent) _add_to_entries_if_not_nan(net, "trafo", entries, index, "mag0_percent", mag0_percent) _add_to_entries_if_not_nan(net, "trafo", entries, index, "mag0_rx", mag0_rx) _add_to_entries_if_not_nan(net, "trafo", entries, index, "si0_hv_partial", si0_hv_partial) _add_to_entries_if_not_nan(net, "trafo", entries, index, "max_loading_percent", max_loading_percent) - _add_to_entries_if_not_nan(net, "trafo", entries, index, "vector_group", vector_group, dtype=str) - _add_to_entries_if_not_nan(net, "trafo", entries, index, "oltc", oltc, bool_, False) + _add_to_entries_if_not_nan(net, "trafo", entries, index, "vector_group", vector_group) + # _add_to_entries_if_not_nan(net, "trafo", entries, index, "oltc", oltc, bool_, False) _add_to_entries_if_not_nan(net, "trafo", entries, index, "pt_percent", pt_percent) _add_to_entries_if_not_nan(net, "trafo", entries, index, "xn_ohm", xn_ohm) - _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_side", tap2_side, dtype=str) + _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_side", tap2_side) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_neutral", tap2_neutral) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_min", tap2_min) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_max", tap2_max) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_step_percent", tap2_step_percent) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_step_degree", tap2_step_degree) _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_pos", tap2_pos) - _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_changer_type", tap2_changer_type, dtype=object) - - defaults_to_fill = [("tap_dependency_table", False)] + _add_to_entries_if_not_nan(net, "trafo", entries, index, "tap2_changer_type", tap2_changer_type) for key in ["tap_dependent_impedance", "vk_percent_characteristic", "vkr_percent_characteristic"]: if key in kwargs: @@ -695,7 +704,7 @@ def create_transformers_from_parameters( # index missing ? ) ) - _set_multiple_entries(net, "trafo", index, defaults_to_fill=defaults_to_fill, entries=entries) + _set_multiple_entries(net, "trafo", index, entries=entries) if any(key in kwargs for key in ["tap_phase_shifter", "tap2_phase_shifter"]): convert_trafo_pst_logic(net) @@ -715,14 +724,14 @@ def create_transformer3w( mv_bus: Int, lv_bus: Int, std_type: str, - name: str | None = None, + name: pd.StringDtype = pd.NA, tap_pos: int | float = nan, - in_service: bool = True, + in_service: bool = get_default_value("trafo3w", "in_service"), index: Int | None = None, max_loading_percent: float = nan, tap_changer_type: TapChangerWithTabularType | None = None, - tap_at_star_point: bool = False, - tap_dependency_table: bool = False, + tap_at_star_point: bool = get_default_value("trafo3w", "tap_at_star_point"), + tap_dependency_table: bool = pd.NA, id_characteristic_table: int | None = None, **kwargs, ) -> Int: @@ -819,15 +828,12 @@ def create_transformer3w( if type(tap_pos) is float: net.trafo3w.tap_pos = net.trafo3w.tap_pos.astype(float) - dd = pd.DataFrame(entries, index=[index]) - net["trafo3w"] = pd.concat([net["trafo3w"], dd], sort=True).reindex(net["trafo3w"].columns, axis=1) + _set_entries(net, "trafo3w", index, entries=entries) _set_value_if_not_nan(net, index, max_loading_percent, "max_loading_percent", "trafo3w") - _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo3w", dtype="Int64") - _set_value_if_not_nan( - net, index, tap_dependency_table, "tap_dependency_table", "trafo3w", dtype=bool_, default_val=False - ) - _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo3w", dtype=str, default_val=None) + _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo3w") + _set_value_if_not_nan(net, index, tap_dependency_table, "tap_dependency_table", "trafo3w") + _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo3w", default_val=None) for key in [ "tap_dependent_impedance", @@ -859,15 +865,15 @@ def create_transformers3w( mv_buses: Sequence, lv_buses: Sequence, std_type: str, - tap_pos: int | Iterable[int] | float = nan, - name: Iterable[str] | None = None, - in_service: bool | Iterable[bool] = True, + tap_pos: float | Iterable[float] = nan, + name: Iterable[pd.StringDtype] | pd.StringDtype = pd.NA, + in_service: bool | Iterable[bool] = get_default_value("trafo3w", "in_service"), index: Iterable[Int] | None = None, max_loading_percent: float | Iterable[float] = nan, - tap_at_star_point: bool | Iterable[bool] = False, - tap_changer_type: float | Iterable[float] | None = None, - tap_dependency_table: bool | Iterable[bool] = False, - id_characteristic_table: int | Iterable[int] | None = None, + tap_at_star_point: bool | Iterable[bool] = get_default_value("trafo3w", "tap_at_star_point"), + tap_changer_type: float | Iterable[float] = nan, + tap_dependency_table: bool | Iterable[bool] = pd.NA, + id_characteristic_table: int | Iterable[int] = pd.NA, **kwargs, ) -> npt.NDArray[Int]: """ @@ -906,14 +912,32 @@ def create_transformers3w( } required_params = ( - "sn_hv_mva", "sn_mv_mva", "sn_lv_mva", "vn_hv_kv", "vn_mv_kv", "vn_lv_kv", - "vk_hv_percent", "vk_mv_percent", "vk_lv_percent", - "vkr_hv_percent", "vkr_mv_percent", "vkr_lv_percent", "pfe_kw", "i0_percent") + "sn_hv_mva", + "sn_mv_mva", + "sn_lv_mva", + "vn_hv_kv", + "vn_mv_kv", + "vn_lv_kv", + "vk_hv_percent", + "vk_mv_percent", + "vk_lv_percent", + "vkr_hv_percent", + "vkr_mv_percent", + "vkr_lv_percent", + "pfe_kw", + "i0_percent", + ) if not all(param in std_params for param in required_params): raise ValueError(f"std_type is missing a required value. Required values: {', '.join(required_params)}") params_from_std_type = ( - "tap_neutral", "tap_max", "tap_min", "tap_side", "tap_step_percent", "tap_step_degree", "tap_changer_type", - *required_params + "tap_neutral", + "tap_max", + "tap_min", + "tap_side", + "tap_step_percent", + "tap_step_degree", + "tap_changer_type", + *required_params, ) params.update({param: std_params[param] for param in params_from_std_type if param in std_params}) @@ -922,10 +946,21 @@ def create_transformers3w( params.update(kwargs) return create_transformers3w_from_parameters( - net=net, hv_buses=hv_buses, mv_buses=mv_buses, lv_buses=lv_buses, name=name, tap_pos=tap_pos, std_type=std_type, - in_service=in_service, max_loading_percent=max_loading_percent, tap_dependency_table=tap_dependency_table, - id_characteristic_table=id_characteristic_table, tap_at_star_point=tap_at_star_point, index=index, - **params + net=net, + hv_buses=hv_buses, + mv_buses=mv_buses, + lv_buses=lv_buses, + name=name, + tap_pos=tap_pos, + std_type=std_type, + in_service=in_service, + max_loading_percent=max_loading_percent, + tap_dependency_table=tap_dependency_table, + id_characteristic_table=id_characteristic_table, + tap_at_star_point=tap_at_star_point, + index=index, + tap_step_degree=0.0, + **params, ) @@ -948,8 +983,8 @@ def create_transformer3w_from_parameters( vkr_lv_percent: float, pfe_kw: float, i0_percent: float, - shift_mv_degree: float = 0.0, - shift_lv_degree: float = 0.0, + shift_mv_degree: float = get_default_value("trafo3w", "shift_mv_degree"), + shift_lv_degree: float = get_default_value("trafo3w", "shift_lv_degree"), tap_side: HVMVLVType | None = None, tap_step_percent: float = nan, tap_step_degree: float = nan, @@ -958,11 +993,11 @@ def create_transformer3w_from_parameters( tap_max: int | float = nan, tap_changer_type: TapChangerWithTabularType | None = None, tap_min: float | None = nan, - name: str | None = None, - in_service: bool = True, + name: pd.StringDtype = pd.NA, + in_service: bool = get_default_value("trafo3w", "in_service"), index: Int | None = None, max_loading_percent: float = nan, - tap_at_star_point: bool = False, + tap_at_star_point: bool = get_default_value("trafo3w", "tap_at_star_point"), vk0_hv_percent: float = nan, vk0_mv_percent: float = nan, vk0_lv_percent: float = nan, @@ -970,7 +1005,7 @@ def create_transformer3w_from_parameters( vkr0_mv_percent: float = nan, vkr0_lv_percent: float = nan, vector_group: str | None = None, - tap_dependency_table: bool = False, + tap_dependency_table: bool = pd.NA, id_characteristic_table: int | None = None, **kwargs, ) -> Int: @@ -1027,7 +1062,7 @@ def create_transformer3w_from_parameters( vkr0_mv_percent: zero sequence real part of short circuit voltage from medium to low voltage vkr0_lv_percent: zero sequence real part of short circuit voltage from high to low voltage vector_group: vector group of the 3w-transformer - + Returns: The ID of the created 3w-transformer @@ -1100,7 +1135,7 @@ def create_transformer3w_from_parameters( "tap_min": tap_min, "in_service": in_service, "name": name, - "std_type": None, + "std_type": pd.NA, "tap_at_star_point": tap_at_star_point, "vk0_hv_percent": vk0_hv_percent, "vk0_mv_percent": vk0_mv_percent, @@ -1114,11 +1149,9 @@ def create_transformer3w_from_parameters( _set_entries(net, "trafo3w", index, entries=entries) _set_value_if_not_nan(net, index, max_loading_percent, "max_loading_percent", "trafo3w") - _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo3w", dtype="Int64") - _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo3w", dtype=str, default_val=None) - _set_value_if_not_nan( - net, index, tap_dependency_table, "tap_dependency_table", "trafo3w", dtype=bool_, default_val=False - ) + _set_value_if_not_nan(net, index, id_characteristic_table, "id_characteristic_table", "trafo3w") + _set_value_if_not_nan(net, index, tap_changer_type, "tap_changer_type", "trafo3w", default_val=None) + _set_value_if_not_nan(net, index, tap_dependency_table, "tap_dependency_table", "trafo3w") return index @@ -1142,30 +1175,30 @@ def create_transformers3w_from_parameters( # no index ? vkr_lv_percent: float | Iterable[float], pfe_kw: float | Iterable[float], i0_percent: float | Iterable[float], - shift_mv_degree: float | Iterable[float] = 0.0, - shift_lv_degree: float | Iterable[float] = 0.0, - tap_side: HVMVLVType | Iterable[str] | None = None, + shift_mv_degree: float | Iterable[float] = get_default_value("trafo3w", "shift_mv_degree"), + shift_lv_degree: float | Iterable[float] = get_default_value("trafo3w", "shift_lv_degree"), + tap_side: HVMVLVType | Iterable[str] = pd.NA, tap_step_percent: float | Iterable[float] = nan, tap_step_degree: float | Iterable[float] = nan, - tap_pos: int | Iterable[int] | float = nan, - tap_neutral: int | Iterable[int] | float = nan, - tap_max: int | Iterable[int] | float = nan, - tap_min: int | Iterable[int] | float = nan, - name: Iterable[str] | None = None, - in_service: bool | Iterable[bool] = True, + tap_pos: float | Iterable[float] = nan, + tap_neutral: float | Iterable[float] = nan, + tap_max: float | Iterable[float] = nan, + tap_min: float | Iterable[float] = nan, + name: Iterable[pd.StringDtype] | pd.StringDtype = pd.NA, + in_service: bool | Iterable[bool] = get_default_value("trafo3w", "in_service"), index: Iterable[Int] | None = None, max_loading_percent: float | Iterable[float] = nan, - tap_at_star_point: bool | Iterable[bool] = False, - tap_changer_type: float | Iterable[float] | None = None, + tap_at_star_point: bool | Iterable[bool] = pd.NA, + tap_changer_type: str | Iterable[str] = pd.NA, vk0_hv_percent: float | Iterable[float] = nan, vk0_mv_percent: float | Iterable[float] = nan, vk0_lv_percent: float | Iterable[float] = nan, vkr0_hv_percent: float | Iterable[float] = nan, vkr0_mv_percent: float | Iterable[float] = nan, vkr0_lv_percent: float | Iterable[float] = nan, - vector_group: str | Iterable[str] | None = None, - tap_dependency_table: bool | Iterable[bool] = False, - id_characteristic_table: int | Iterable[int] | None = None, + vector_group: str | Iterable[str] = pd.NA, + tap_dependency_table: bool | Iterable[bool] = pd.NA, + id_characteristic_table: int | Iterable[int] = pd.NA, **kwargs, ) -> npt.NDArray[integer]: """ @@ -1273,8 +1306,8 @@ def create_transformers3w_from_parameters( # no index ? "tap_min": tap_min, "in_service": array(in_service).astype(bool_), "name": name, - "tap_at_star_point": array(tap_at_star_point).astype(bool_), - "std_type": None, + "tap_at_star_point": array(tap_at_star_point), + "std_type": pd.NA, "vk0_hv_percent": vk0_hv_percent, "vk0_mv_percent": vk0_mv_percent, "vk0_lv_percent": vk0_lv_percent, @@ -1287,13 +1320,8 @@ def create_transformers3w_from_parameters( # no index ? } _add_to_entries_if_not_nan(net, "trafo3w", entries, index, "max_loading_percent", max_loading_percent) - _add_to_entries_if_not_nan( - net, "trafo3w", entries, index, "id_characteristic_table", id_characteristic_table, dtype="Int64" - ) - _add_to_entries_if_not_nan( - net, "trafo3w", entries, index, "tap_changer_type", tap_changer_type, dtype=str, default_val=None - ) - defaults_to_fill = [("tap_dependency_table", False)] + _add_to_entries_if_not_nan(net, "trafo3w", entries, index, "id_characteristic_table", id_characteristic_table) + _add_to_entries_if_not_nan(net, "trafo3w", entries, index, "tap_changer_type", tap_changer_type) for key in [ "tap_dependent_impedance", @@ -1316,6 +1344,6 @@ def create_transformers3w_from_parameters( # no index ? ) ) - _set_multiple_entries(net, "trafo3w", index, defaults_to_fill=defaults_to_fill, entries=entries) + _set_multiple_entries(net, "trafo3w", index, entries=entries) return index diff --git a/pandapower/create/ward_create.py b/pandapower/create/ward_create.py index 37c1197d83..1c730a3a04 100644 --- a/pandapower/create/ward_create.py +++ b/pandapower/create/ward_create.py @@ -21,6 +21,7 @@ _set_entries, _set_multiple_entries, ) +from pandapower.network_structure import get_default_value logger = logging.getLogger(__name__) @@ -33,7 +34,7 @@ def create_ward( pz_mw: float, qz_mvar: float, name: str | None = None, - in_service: bool = True, + in_service: bool = get_default_value("ward", "in_service"), index: Int | None = None, **kwargs, ) -> Int: @@ -80,7 +81,7 @@ def create_wards( pz_mw: float | Iterable[float], qz_mvar: float | Iterable[float], name: Iterable[str] | None = None, - in_service: bool | Iterable[bool] = True, + in_service: bool | Iterable[bool] = get_default_value("ward", "in_service"), index: int | None = None, **kwargs, ) -> npt.NDArray[np.array]: @@ -130,10 +131,10 @@ def create_xward( r_ohm: float, x_ohm: float, vm_pu: float, - in_service: bool = True, + in_service: bool = get_default_value("xward", "in_service"), name: str | None = None, index: Int | None = None, - slack_weight: float = 0.0, + slack_weight: float = get_default_value("xward", "slack_weight"), **kwargs, ): """ diff --git a/pandapower/diagnostic.py b/pandapower/diagnostic.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/diagnostic/diagnostic_functions.py b/pandapower/diagnostic/diagnostic_functions.py index 90d7b3f300..6e42488c7e 100644 --- a/pandapower/diagnostic/diagnostic_functions.py +++ b/pandapower/diagnostic/diagnostic_functions.py @@ -13,7 +13,7 @@ get_connected_buses_at_element, get_connected_elements ) from pandapower.create import create_impedance, create_switch -from pandapower.run import runpp +from pandapower.run import runpp, rundcpp from pandapower.auxiliary import ( ADict, pandapowerNet, @@ -34,7 +34,8 @@ check_greater_equal_zero, check_switch_type, check_less_equal_zero, - check_greater_zero_less_equal_one + check_greater_zero_less_equal_one, + check_vkr_larger ) logger = logging.getLogger(__name__) @@ -44,6 +45,8 @@ default_argument_values = { "overload_scaling_factor": 0.001, "capacitance_scaling_factor": 0.01, + "resistance_scaling_factor": 0.01, + "reactance_scaling_factor": 0.01, "min_r_ohm": 0.001, "min_x_ohm": 0.001, "max_r_ohm": 100., @@ -98,6 +101,7 @@ def diagnostic(self, net: pandapowerNet, **kwargs) -> dict[str, Any] | None: ("vkr_percent", ">=0"), ("vk_percent", ">0"), ("vkr_percent", "<15"), + ("vkr_percent", "vkr_percent_larger"), ("vk_percent", "<20"), ("pfe_kw", ">=0"), ("i0_percent", ">=0"), @@ -114,8 +118,11 @@ def diagnostic(self, net: pandapowerNet, **kwargs) -> dict[str, Any] | None: ("vn_mv_kv", ">0"), ("vn_lv_kv", ">0"), ("vkr_hv_percent", ">=0"), + ("vkr_hv_percent", "vkr_percent_larger"), ("vkr_mv_percent", ">=0"), + ("vkr_mv_percent", "vkr_percent_larger"), ("vkr_lv_percent", ">=0"), + ("vkr_lv_percent", "vkr_percent_larger"), ("vk_hv_percent", ">0"), ("vk_mv_percent", ">0"), ("vk_lv_percent", ">0"), @@ -175,11 +182,13 @@ def diagnostic(self, net: pandapowerNet, **kwargs) -> dict[str, Any] | None: "number": check_number, "0 0: for value in important_values[key]: + # every element is checked separately, TODO: implement vector based checks for i, element in net[key].iterrows(): check_result = type_checks[value[1]](element, i, value[0]) if check_result is not None: @@ -419,6 +428,128 @@ def report(self, error: Exception | None, results: dict[str, bool] | None) -> No self.out.warning(f"overload found: Power flow converges with generation scaled down to {osf_percent}") +class CheckDCPowerflow(DiagnosticFunction[pandapowerNet, bool]): + """ + Checks, if a dc powerflow calculation converges. + """ + def __init__(self) -> None: + super().__init__() + + def diagnostic(self, net: pandapowerNet, **kwargs) -> bool | None: + """ + :param pandapowerNet net: pandapower network + :param kwargs: Keyword arguments for power flow function. If "run" is in kwargs the default call to runpp() + is replaced by the function kwargs["run"] + + :returns: dict with the results of the overload check + Format: {'load_overload': True/False, 'generation_overload', True/False} + """ + # get function to run power flow + run = partial(kwargs.pop("run", rundcpp), **kwargs) + check_result = None + + try: + run(net) + except expected_exceptions: + check_result = False + + except Exception as e: + self.out.error(f"rundcpp failed: {str(e)}") + raise e + + return check_result + + def report(self, error: Exception | None, results: bool | None) -> None: + # error and success checks + if error is not None: + self.out.warning("Check for convergence error failed due to the following error:") + self.out.warning(error) + return + if results is None: + self.out.info("PASSED: Power flow converges. No line capacitance problems found.") + return + + # message header + self.out.compact("line problems:\n") + self.out.detailed("Checking for too high line capacitance...\n") + + # message body + if self.capacitance_scaling_factor is not None: + capacitance_scaling_factor = self.capacitance_scaling_factor + else: + raise RuntimeError('diagnostic was not executed before calling results?') + + osf_percent = f"{capacitance_scaling_factor * 100} percent." + + if results: + self.out.warning( + f"Too high capacitance found: Power flow converges with line.c_nf_per_km scaled down to {osf_percent}") + else: + self.out.warning( + f"Too high capacitance tested: Power flow did not converge with line.c_nf_per_km scaled down to {osf_percent}") + + +class DisableVoltageDependentLoads(DiagnosticFunction[pandapowerNet, bool]): + """ + Checks, if a loadflow calculation converges, if voltage_depend_loads is set to False. + """ + def __init__(self) -> None: + super().__init__() + + def diagnostic(self, net: pandapowerNet, **kwargs) -> bool | None: + """ + :param pandapowerNet net: pandapower network + :param kwargs: Keyword arguments for power flow function. If "run" is in kwargs the default call to runpp() + is replaced by the function kwargs["run"] + + :returns: dict with the results of the overload check + Format: {'load_overload': True/False, 'generation_overload', True/False} + """ + # get function to run power flow + run = partial(kwargs.pop("run", runpp), **kwargs) + check_result = None + + try: + run(net, voltage_depend_loads=False) + except expected_exceptions: + check_result = False + + except Exception as e: + self.out.error(f"voltage_depend_loads check failed: {str(e)}") + raise e + + return check_result + + def report(self, error: Exception | None, results: bool | None) -> None: + # error and success checks + if error is not None: + self.out.warning("Check for convergence error failed due to the following error:") + self.out.warning(error) + return + if results is None: + self.out.info("PASSED: Power flow converges. No line capacitance problems found.") + return + + # message header + self.out.compact("line problems:\n") + self.out.detailed("Checking for too high line capacitance...\n") + + # message body + if self.capacitance_scaling_factor is not None: + capacitance_scaling_factor = self.capacitance_scaling_factor + else: + raise RuntimeError('diagnostic was not executed before calling results?') + + osf_percent = f"{capacitance_scaling_factor * 100} percent." + + if results: + self.out.warning( + f"Too high capacitance found: Power flow converges with line.c_nf_per_km scaled down to {osf_percent}") + else: + self.out.warning( + f"Too high capacitance tested: Power flow did not converge with line.c_nf_per_km scaled down to {osf_percent}") + + class WrongLineCapacitance(DiagnosticFunction[pandapowerNet, bool]): """ Checks, if a loadflow calculation converges. If not, checks, if line capacitance is too high, by scaling it to 1%. @@ -496,6 +627,160 @@ def report(self, error: Exception | None, results: bool | None) -> None: f"Too high capacitance tested: Power flow did not converge with line.c_nf_per_km scaled down to {osf_percent}") +class WrongLineReactance(DiagnosticFunction[pandapowerNet, bool]): + """ + Checks, if a loadflow calculation converges. If not, checks, if line reactance is too high, by scaling it to 1%. + """ + def __init__(self) -> None: + super().__init__() + self.reactance_scaling_factor: float | None = None + + def diagnostic(self, net: pandapowerNet, **kwargs) -> bool | None: + """ + :param pandapowerNet net: pandapower network + :param kwargs: Keyword arguments for power flow function. If "run" is in kwargs the default call to runpp() + is replaced by the function kwargs["run"] + + :returns: dict with the results of the overload check + Format: {'load_overload': True/False, 'generation_overload', True/False} + """ + # get function to run power flow + run = partial(kwargs.pop("run", runpp), **kwargs) + check_result = None + line_reactance = copy.copy(net.line.x_ohm_per_km) + + reactance_scaling_factor = kwargs.pop( + "reactance_scaling_factor", default_argument_values["reactance_scaling_factor"] + ) + + self.reactance_scaling_factor = reactance_scaling_factor + try: + run(net) + except expected_exceptions: + check_result = False + try: + net.line.x_ohm_per_km *= reactance_scaling_factor + run(net) + check_result = True + except expected_exceptions: + self.out.debug("Line reactance check failed.") + + except Exception as e: + self.out.error(f"Line reactance check failed: {str(e)}") + raise e + + # teardown + net.line.x_ohm_per_km = line_reactance + + return check_result + + def report(self, error: Exception | None, results: bool | None) -> None: + # error and success checks + if error is not None: + self.out.warning("Check for convergence error failed due to the following error:") + self.out.warning(error) + return + if results is None: + self.out.info("PASSED: Power flow converges. No line capacitance problems found.") + return + + # message header + self.out.compact("line problems:\n") + self.out.detailed("Checking for too high line capacitance...\n") + + # message body + if self.capacitance_scaling_factor is not None: + capacitance_scaling_factor = self.capacitance_scaling_factor + else: + raise RuntimeError('diagnostic was not executed before calling results?') + + osf_percent = f"{capacitance_scaling_factor * 100} percent." + + if results: + self.out.warning( + f"Too high capacitance found: Power flow converges with line.c_nf_per_km scaled down to {osf_percent}") + else: + self.out.warning( + f"Too high capacitance tested: Power flow did not converge with line.c_nf_per_km scaled down to {osf_percent}") + + +class WrongLineResistance(DiagnosticFunction[pandapowerNet, bool]): + """ + Checks, if a loadflow calculation converges. If not, checks, if line resistance is too high, by scaling it to 1%. + """ + def __init__(self) -> None: + super().__init__() + self.resistance_scaling_factor: float | None = None + + def diagnostic(self, net: pandapowerNet, **kwargs) -> bool | None: + """ + :param pandapowerNet net: pandapower network + :param kwargs: Keyword arguments for power flow function. If "run" is in kwargs the default call to runpp() + is replaced by the function kwargs["run"] + + :returns: dict with the results of the overload check + Format: {'load_overload': True/False, 'generation_overload', True/False} + """ + # get function to run power flow + run = partial(kwargs.pop("run", runpp), **kwargs) + check_result = None + line_resistance = copy.copy(net.line.r_ohm_per_km) + + resistance_scaling_factor = kwargs.pop( + "resistance_scaling_factor", default_argument_values["resistance_scaling_factor"] + ) + + self.resistance_scaling_factor = resistance_scaling_factor + try: + run(net) + except expected_exceptions: + check_result = False + try: + net.line.r_ohm_per_km *= resistance_scaling_factor + run(net) + check_result = True + except expected_exceptions: + self.out.debug("Line resistance check failed.") + + except Exception as e: + self.out.error(f"Line resistance check failed: {str(e)}") + raise e + + # teardown + net.line.r_ohm_per_km = line_resistance + + return check_result + + def report(self, error: Exception | None, results: bool | None) -> None: + # error and success checks + if error is not None: + self.out.warning("Check for convergence error failed due to the following error:") + self.out.warning(error) + return + if results is None: + self.out.info("PASSED: Power flow converges. No line capacitance problems found.") + return + + # message header + self.out.compact("line problems:\n") + self.out.detailed("Checking for too high line capacitance...\n") + + # message body + if self.capacitance_scaling_factor is not None: + capacitance_scaling_factor = self.capacitance_scaling_factor + else: + raise RuntimeError('diagnostic was not executed before calling results?') + + osf_percent = f"{capacitance_scaling_factor * 100} percent." + + if results: + self.out.warning( + f"Too high capacitance found: Power flow converges with line.c_nf_per_km scaled down to {osf_percent}") + else: + self.out.warning( + f"Too high capacitance tested: Power flow did not converge with line.c_nf_per_km scaled down to {osf_percent}") + + class SubNetProblemTest(DiagnosticFunction[pandapowerNet, dict[str, bool]]): """ Checks, if subnets are converging. This is done using the zone attribute. @@ -1685,12 +1970,12 @@ def diagnostic(self, net: pandapowerNet, **kwargs) -> dict[str, dict] | None: """ check_results: dict[str, dict] = defaultdict(dict) for key, std_types in net.std_types.items(): - if key not in net: + if key not in net or "std_type" not in net[key]: continue for i, element in net[key].iterrows(): std_type = element.std_type if std_type not in std_types: - if std_type is not None: + if pd.notna(std_type): check_results[key][i] = {"std_type_in_lib": False} continue std_type_values = std_types[std_type] @@ -1796,6 +2081,8 @@ def report(self, error: Exception | None, results: list[list] | None) -> None: ("invalid_values", InvalidValues(), []), ("overload", Overload(), None), ("wrong_line_capacitance", WrongLineCapacitance(), None), + ("wrong_line_resistance", WrongLineResistance(), None), + ("wrong_line_reactance", WrongLineReactance(), None), ("wrong_switch_configuration", WrongSwitchConfiguration(), None), ("test_subnet_from_zone", SubNetProblemTest(), None), ("test_continuous_bus_indices", TestContinuousBusIndices(), None), @@ -1805,5 +2092,7 @@ def report(self, error: Exception | None, results: list[list] | None) -> None: ("deviation_from_std_type", DeviationFromStdType(), []), ("numba_comparison", NumbaComparison(), None), ("parallel_switches", ParallelSwitches(), []), - ("optimistic_powerflow", OptimisticPowerflow(), None) + ("optimistic_powerflow", OptimisticPowerflow(), None), + ("disable_voltage_dependent_loads", DisableVoltageDependentLoads(), None), + ("check_dc_powerflow", CheckDCPowerflow(), None) ] diff --git a/pandapower/diagnostic/diagnostic_helpers.py b/pandapower/diagnostic/diagnostic_helpers.py index bc6cdfa6fe..5283aea36e 100644 --- a/pandapower/diagnostic/diagnostic_helpers.py +++ b/pandapower/diagnostic/diagnostic_helpers.py @@ -245,6 +245,21 @@ def check_switch_type(element, element_index, column): return None +def check_vkr_larger(element, element_index, column): + if column not in ['vkr_percent', 'vkr_hv_percent', 'vkr_mv_percent', 'vkr_lv_percent'] or column not in element: + raise TypeError(f"{element}, {column}, not a valid argument for checking vkr_percent.") + + # if vkr_percent, the real part of vk, is larger than vk_percent: Error. (Same for vkr_hv_percent ...) + try: + if element[column] > element[f'vk_{column[4:]}']: + return element_index + except TypeError: + return element_index + return None + + + + def diagnostic( net: pandapowerNet, report_style: Literal['compact', 'detailed'] | None = 'detailed', diff --git a/pandapower/grid_equivalents/auxiliary.py b/pandapower/grid_equivalents/auxiliary.py index fc23b9e7e1..72886ead15 100644 --- a/pandapower/grid_equivalents/auxiliary.py +++ b/pandapower/grid_equivalents/auxiliary.py @@ -181,50 +181,37 @@ def calc_zpbn_parameters(net, boundary_buses, all_external_buses, slack_as="gen" """ # runpp_fct(net, calculate_voltage_angles=True) be_buses = boundary_buses + all_external_buses - if ((net.trafo.hv_bus.isin(be_buses)) & (net.trafo.shift_degree != 0)).any() \ - or ((net.trafo3w.hv_bus.isin(be_buses)) & \ - ((net.trafo3w.shift_mv_degree != 0) | (net.trafo3w.shift_lv_degree != 0))).any(): + if (((net.trafo.hv_bus.isin(be_buses)) & (net.trafo.shift_degree != 0)).any() or + ((net.trafo3w.hv_bus.isin(be_buses)) & ((net.trafo3w.shift_mv_degree != 0) | + (net.trafo3w.shift_lv_degree != 0))).any()): existing_shift_degree = True - logger.info("Transformers with non-zero shift-degree are existed," + - " they could cause small inaccuracy.") - # creata dataframe to collect the current injections of the external area + logger.info("Transformers with non-zero shift-degree are existed, they could cause small inaccuracy.") + # create dataframe to collect the current injections of the external area nb_ext_buses = len(all_external_buses) S = pd.DataFrame(np.zeros((nb_ext_buses, 15)), dtype=complex) - S.columns = ["ext_bus", "v_m", "v_cpx", "gen_integrated", "gen_separate", - "load_integrated", "load_separate", "sgen_integrated", - "sgen_separate", "sn_load_separate", "sn_load_integrated", - "sn_sgen_separate", "sn_sgen_integrated", "sn_gen_separate", - "sn_gen_integrated"] + S.columns = ["ext_bus", "v_m", "v_cpx", "gen_integrated", "gen_separate", "load_integrated", "load_separate", + "sgen_integrated", "sgen_separate", "sn_load_separate", "sn_load_integrated", "sn_sgen_separate", + "sn_sgen_integrated", "sn_gen_separate", "sn_gen_integrated"] k, ind = 0, 0 - if slack_as == "gen": - elements = set([("load", "res_load", "load_separate", "sn_load_separate", -1), - ("sgen", "res_sgen", "sgen_separate", "sn_sgen_separate", 1), - ("gen", "res_gen", "gen_separate", "sn_gen_separate", 1), - ("ext_grid", "res_ext_grid", "gen_separate", "sn_gen_separate", 1)]) - - elif slack_as == "load": - elements = set([("load", "res_load", "load_separate", "sn_load_separate", -1), - ("sgen", "res_sgen", "sgen_separate", "sn_sgen_separate", 1), - ("gen", "res_gen", "gen_separate", "sn_gen_separate", 1), - ("ext_grid", "res_ext_grid", "load_separate", "sn_load_separate", 1)]) for i in all_external_buses: - for ele, res_ele, power, sn, sign in elements: + for ele in ["load", "sgen", "gen", "ext_grid"]: if i in net[ele].bus.values and net[ele].in_service[net[ele].bus == i].values.any(): + res_ele = f"res_{ele}" + power = ele if ele != "ext_grid" else slack_as + sign = -1 if ele == "load" else 1 ind = list(net[ele].index[net[ele].bus == i].values) # act. values --> ref. values: - S.loc[k, power] += sum(net[res_ele].p_mw[ind].values * sign) / net.sn_mva + \ - 1j * sum(net[res_ele].q_mvar[ind].values * - sign) / net.sn_mva - S.loc[k, sn] = sum(net[ele].sn_mva[ind].values) + \ - 1j * 0 if ele != "ext_grid" else 1e6 + 1j * 0 - S[power.replace('_separate', '_integrated')] += S[power][k] - S[sn.replace('_separate', '_integrated')] += S[sn][k] + S.loc[k, f"{power}_separate"] += (sum(net[res_ele].p_mw[ind].values * sign) / net.sn_mva + + sum(net[res_ele].q_mvar[ind].values * sign) / net.sn_mva * 1j) + ele_sn_mva = float('nan') if 'sn_mva' not in net[ele].columns else sum(net[ele].loc[ind, 'sn_mva']) + S.loc[k, f"sn_{power}_separate"] = ele_sn_mva + 1j * 0 if ele != "ext_grid" else 1e6 + 1j * 0 + S[f"{power}_integrated"] += S.loc[k, f"{power}_separate"] + S[f"sn_{power}_integrated"] += S.loc[k, f"sn_{power}_separate"] S.loc[k, 'ext_bus'] = all_external_buses[k] S.loc[k, 'v_m'] = net.res_bus.vm_pu[i] - S.loc[k, 'v_cpx'] = S.v_m[k] * \ - np.exp(1j * net.res_bus.va_degree[i] * np.pi / 180) + S.loc[k, 'v_cpx'] = S.v_m[k] * np.exp(1j * net.res_bus.va_degree[i] * np.pi / 180) k = k + 1 # create dataframe to calculate the impedance of the ZPBN-network @@ -237,25 +224,20 @@ def calc_zpbn_parameters(net, boundary_buses, all_external_buses, slack_as="gen" for elm in ["load", "gen", "sgen"]: if existing_shift_degree: - Y[elm + "_ground"] = (S[elm + "_separate"].values / S.v_cpx.values).conjugate() / \ - S.v_cpx.values + Y[elm + "_ground"] = (S[elm + "_separate"].values / S.v_cpx.values).conjugate() / S.v_cpx.values else: - Y[elm + "_ground"] = S[elm + "_separate"].values.conjugate() / \ - np.square(S.v_m) - I_elm_integrated_total = sum((S[elm + "_separate"].values / - S.v_cpx.values).conjugate()) + Y[elm + "_ground"] = S[elm + "_separate"].values.conjugate() / np.square(S.v_m) + I_elm_integrated_total = sum((S[elm + "_separate"].values / S.v_cpx.values).conjugate()) if I_elm_integrated_total == 0: Y[elm + "_integrated_total"] = float("nan") else: - vm_elm_integrated_total = S[elm + "_integrated"][0] / \ - I_elm_integrated_total.conjugate() + vm_elm_integrated_total = S[elm + "_integrated"][0] / I_elm_integrated_total.conjugate() if existing_shift_degree: - Y[elm + "_integrated_total"] = (-S[elm + "_integrated"][0] / \ - vm_elm_integrated_total).conjugate() / \ - vm_elm_integrated_total + Y[elm + "_integrated_total"] = ((-S[elm + "_integrated"][0] / vm_elm_integrated_total).conjugate() / + vm_elm_integrated_total) else: - Y[elm + "_integrated_total"] = -S[elm + "_integrated"][0].conjugate() / \ - np.square(abs(vm_elm_integrated_total)) + Y[elm + "_integrated_total"] = (-S[elm + "_integrated"][0].conjugate() / + np.square(abs(vm_elm_integrated_total))) Y[elm + "_separate_total"] = -Y[elm + "_ground"] if elm == "gen" and any(S.gen_separate): v["gen_integrated_vm_total"] = abs(vm_elm_integrated_total) @@ -292,8 +274,11 @@ def drop_assist_elms_by_creating_ext_net(net, elms=None): if elms is None: elms = ["ext_grid", "bus", "impedance"] for elm in elms: - target_elm_idx = net[elm].index[net[elm].name.astype(str).str.contains( - "assist_" + elm, na=False, regex=False)] + if 'name' in net[elm].columns: + names = net[elm].name.str.contains("assist_" + elm, na=False, regex=False) + else: + names = pd.Series(False, index=net[elm].index) + target_elm_idx = net[elm].index[names] net[elm] = net[elm].drop(target_elm_idx) if net["res_" + elm].shape[0]: res_target_elm_idx = net["res_" + @@ -334,7 +319,7 @@ def build_ppc_and_Ybus(net): net._ppc["internal"]["Ybus"] = Ybus -def drop_measurements_and_controllers(net, buses, skip_controller=False): +def drop_measurements_and_controllers(net, buses): """This function drops the measurements of the given buses. Also, the related controller parameters will be removed. """ # --- dropping measurements @@ -370,8 +355,7 @@ def ensure_origin_id(net, elms=None): net[elm].loc[idxs, "origin_id"] = ["%s_%i_%s" % (elm, idx, str(uuid.uuid4())) for idx in idxs] -def drop_and_edit_cost_functions(net, buses, drop_cost, add_origin_id, - check_unique_elms_name=True): +def drop_and_edit_cost_functions(net, buses, drop_cost, add_origin_id): """ This function drops the ploy_cost/pwl_cost data related to the given buses. @@ -462,17 +446,12 @@ def get_boundary_vp(net_eq, bus_lookups): return v_boundary, p_boundary -def adaptation_phase_shifter(net, v_boundary, p_boundary): +def adaptation_phase_shifter(net, v_boundary): target_buses = list(v_boundary.bus.values) phase_errors = v_boundary.va_degree.values - \ net.res_bus.va_degree[target_buses].values vm_errors = v_boundary.vm_pu.values - \ net.res_bus.vm_pu[target_buses].values - # p_errors = p_boundary.p_mw.values - \ - # net.res_bus.p_mw[target_buses].values - # q_errors = p_boundary.q_mvar.values - \ - # net.res_bus.q_mvar[target_buses].values - # print(q_errors) for idx, lb in enumerate(target_buses): if abs(vm_errors[idx]) > 1e-6 and abs(vm_errors[idx]) > 1e-6: hb = create_bus(net, net.bus.vn_kv[lb] * (1 - vm_errors[idx]), @@ -480,7 +459,10 @@ def adaptation_phase_shifter(net, v_boundary, p_boundary): elm_dict = get_connected_elements_dict(net, lb) for e, e_list in elm_dict.items(): for i in e_list: - name = str(net[e].name[i]) + if 'name' in net[e]: + name = str(net[e].name[i]) + else: + name = '' if "eq_" not in name and "_integrated_" not in name and \ "_separate_" not in name: if e in ["impedance", "line"]: @@ -501,7 +483,7 @@ def adaptation_phase_shifter(net, v_boundary, p_boundary): else: net[e].lv_bus[i] == lb elif e in ["bus", "load", "sgen", "gen", "shunt", "ward", "xward"]: - pass + continue else: net[e].loc[i, 'bus'] = hb create_transformer_from_parameters(net, hb, lb, 1e5, @@ -509,13 +491,8 @@ def adaptation_phase_shifter(net, v_boundary, p_boundary): net.bus.vn_kv[lb], vkr_percent=0, vk_percent=100, pfe_kw=.0, i0_percent=.0, - # shift_degree=-phase_errors[idx], tap_step_degree=-phase_errors[idx], - # tap_phase_shifter=True, name="phase_shifter_adapter_" + str(lb)) - # pp.create_load(net, lb, -p_errors[idx], -q_errors[idx], - # name="phase_shifter_adapter_"+str(lb)) - # runpp_fct(net, calculate_voltage_angles=True) return net @@ -537,7 +514,3 @@ def replace_motor_by_load(net, all_external_buses): net.res_load.loc[li] = p, q net.motor = net.motor.drop(motors) net.res_motor = net.res_motor.drop(motors) - - -if __name__ == "__main__": - pass diff --git a/pandapower/grid_equivalents/get_equivalent.py b/pandapower/grid_equivalents/get_equivalent.py index 6e46d802f1..6ba0afdc01 100644 --- a/pandapower/grid_equivalents/get_equivalent.py +++ b/pandapower/grid_equivalents/get_equivalent.py @@ -1,6 +1,8 @@ import time from copy import deepcopy +import pandas as pd + from pandapower.create import create_group_from_dict from pandapower.grid_equivalents.auxiliary import ( drop_assist_elms_by_creating_ext_net, drop_internal_branch_elements, add_ext_grids_to_boundaries, @@ -262,16 +264,19 @@ def get_equivalent( if kwargs.get("add_group", True): # declare a group for the new equivalent - ib_buses_after_merge, be_buses_after_merge = \ - _get_buses_after_merge(net_eq, net_internal, bus_lookups, return_internal) + ib_buses_after_merge, be_buses_after_merge = _get_buses_after_merge( + net_eq, net_internal, bus_lookups, return_internal + ) eq_elms = {} - for elm in ["bus", "gen", "impedance", "load", "sgen", "shunt", - "switch", "ward", "xward"]: + for elm in ["bus", "gen", "impedance", "load", "sgen", "shunt", "switch", "ward", "xward"]: if "ward" in elm: - new_idx = net_eq[elm].index[net_eq[elm].name == "network_equivalent"].difference( - net[elm].index[net[elm].name == "network_equivalent"]) + if "name" in net[elm].columns: + new_idx = net_eq[elm].index[net_eq[elm].name == "network_equivalent"].difference( + net[elm].index[net[elm].name == "network_equivalent"]) + else: + new_idx = [] else: - names = net_eq[elm].name.astype(str) + names = net_eq[elm].name.astype(str) if "name" in net_eq[elm].columns else pd.Series("", index=net_eq[elm].index) if elm in ["bus", "sgen", "gen", "load"]: buses = net_eq.bus.index if elm == "bus" else net_eq[elm].bus new_idx = net_eq[elm].index[names.str.contains("_integrated") | diff --git a/pandapower/grid_equivalents/rei_generation.py b/pandapower/grid_equivalents/rei_generation.py index 0481f8ab66..533a21be3d 100644 --- a/pandapower/grid_equivalents/rei_generation.py +++ b/pandapower/grid_equivalents/rei_generation.py @@ -120,22 +120,24 @@ def adapt_impedance_params(Z, sign=1, adaption=1e-15): return rft_pu, xft_pu +# TODO: This function should be refactored, it is way to big and dos way to many tasks in one. def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses, load_separate=False, sgen_separate=True, gen_separate=True, show_computing_time=False, calc_volt_angles=True, runpp_fct=_runpp_except_voltage_angles, **kwargs): """ - The function builds the zero power balance network with - calculated impedance and voltage - - INPUT: - **net** - pandapower network - - **boundary_buses** (list) - boundary buses - - **all_internal_buses** - all the internal buses - - **all_external_buses** - all the external buses + The function builds the zero power balance network with calculated impedance and voltage + + Parameters: + net: pandapower network + boundary_buses: boundary buses + all_internal_buses: all the internal buses + all_external_buses: all the external buses + load_separate: flag if all the loads are reserved integrally + sgen_separate: flag if all the DER are reserved separately + gen_separate: flag if all the gens are reserved separately + tolerance_mva: loadflow termination condition referring to P / Q mismatch of node power in MVA. + The loadflow hier is to get the admittance matrix of the zpbn network OPTIONAL: **load_separate** (bool, False) - flag if all the loads @@ -171,7 +173,7 @@ def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses drop_buses(net_zpbn, net_zpbn.res_bus.index[net_zpbn.res_bus.vm_pu.isnull()]) Z, S, v, limits = calc_zpbn_parameters(net_zpbn, boundary_buses, all_external_buses) - # --- remove the original load, sgen and gen in exteranl area, + # --- remove the original load, sgen and gen in external area, # and creat new buses and impedance t_buses, g_buses = [], [] sn_mva = net_zpbn.sn_mva @@ -181,74 +183,75 @@ def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses if elm == "ext_grid": continue - if not np.isnan(Z[elm + "_ground"].values).all(): - if separate: - Z = Z.drop([elm + "_integrated_total"], axis=1) - - # add buses - idxs = Z.index[~np.isnan(Z[elm + "_ground"].values)] - vn_kvs = net_zpbn.bus.vn_kv[Z.ext_bus.loc[idxs]] - new_g_buses = create_buses(net_zpbn, len(idxs), vn_kvs, name=[ - "%s_separate-ground %s" % (elm, str(Z.ext_bus.loc[i])) for i in idxs]) - new_t_buses = create_buses(net_zpbn, len(idxs), vn_kvs, name=[ - "%s_separate-total %s" % (elm, str(Z.ext_bus.loc[i])) for i in idxs], - max_vm_pu=limits.max_vm_pu.loc[idxs], min_vm_pu=limits.min_vm_pu.loc[idxs]) - - # add impedances - rft_pu_g, xft_pu_g = adapt_impedance_params(Z[elm + "_ground"].loc[idxs].values) - max_idx = net_zpbn.impedance.index.max() if net_zpbn.impedance.shape[0] else 0 - new_imps_g = pd.DataFrame({ - "from_bus": Z.ext_bus.loc[idxs].astype(np.int64).values, "to_bus": new_g_buses, - "rft_pu": rft_pu_g, "xft_pu": xft_pu_g, - "rtf_pu": rft_pu_g, "xtf_pu": xft_pu_g, - "gf_pu": 0, "bf_pu": 0, "gt_pu": 0, "bt_pu": 0}, - index=range(max_idx + 1, max_idx + 1 + len(new_g_buses))) - new_imps_g["name"] = "eq_impedance_ext_to_ground" - new_imps_g["sn_mva"] = sn_mva - new_imps_g["in_service"] = True - - rft_pu_t, xft_pu_t = adapt_impedance_params(Z[elm + "_separate_total"].loc[ - idxs].values) - new_imps_t = pd.DataFrame({ - "from_bus": new_g_buses, "to_bus": new_t_buses, - "rft_pu": rft_pu_t, "xft_pu": xft_pu_t, - "rtf_pu": rft_pu_t, "xtf_pu": xft_pu_t, - "gf_pu": 0, "bf_pu": 0, "gt_pu": 0, "bt_pu": 0}, - index=range(new_imps_g.index.max() + 1, - new_imps_g.index.max() + 1 + len(new_g_buses))) - new_imps_t["name"] = "eq_impedance_ground_to_total" - new_imps_t["sn_mva"] = sn_mva - new_imps_t["in_service"] = True - - net_zpbn["impedance"] = pd.concat([net_zpbn["impedance"], new_imps_g, new_imps_t]) - g_buses += list(new_g_buses) - t_buses += list(new_t_buses) - else: - Z = Z.drop([elm + "_separate_total"], axis=1) - vn_kv = net_zpbn.bus.vn_kv[all_external_buses].values[0] - new_g_bus = create_bus(net_zpbn, vn_kv, name=elm + "_integrated-ground ") - i_all_integrated = [] - for i in Z.index[~np.isnan(Z[elm + "_ground"].values)]: - rft_pu, xft_pu = adapt_impedance_params(Z[elm + "_ground"][i]) - create_impedance(net_zpbn, Z.ext_bus[i], new_g_bus, rft_pu, xft_pu, - sn_mva, name="eq_impedance_ext_to_ground") - i_all_integrated.append(i) - # in case of integrated, the tightest vm limits are assumed - ext_buses = Z.ext_bus[~np.isnan(Z[elm + "_ground"])].values - ext_buses_name = "/".join([str(eb) for eb in ext_buses]) - new_t_bus = create_bus( - net_zpbn, vn_kv, name=elm + "_integrated-total " + ext_buses_name, - max_vm_pu=limits.max_vm_pu.loc[i_all_integrated].min(), - min_vm_pu=limits.min_vm_pu.loc[i_all_integrated].max()) - rft_pu, xft_pu = adapt_impedance_params(Z[elm + "_integrated_total"][0]) - create_impedance(net_zpbn, new_g_bus, new_t_bus, rft_pu, xft_pu, - sn_mva, name="eq_impedance_ground_to_total") - g_buses += [new_g_bus.tolist()] - t_buses += [new_t_bus.tolist()] - else: + if np.isnan(Z[elm + "_ground"].values).all(): Z.drop([elm + "_ground", elm + "_separate_total", elm + "_integrated_total"], axis=1, inplace=True) + continue + + if separate: + Z = Z.drop([elm + "_integrated_total"], axis=1) + + # add buses + idxs = Z.index[~np.isnan(Z[elm + "_ground"].values)] + vn_kvs = net_zpbn.bus.vn_kv[Z.ext_bus.loc[idxs]] + new_g_buses = create_buses(net_zpbn, len(idxs), vn_kvs, name=[ + "%s_separate-ground %s" % (elm, str(Z.ext_bus.loc[i])) for i in idxs]) + new_t_buses = create_buses(net_zpbn, len(idxs), vn_kvs, name=[ + "%s_separate-total %s" % (elm, str(Z.ext_bus.loc[i])) for i in idxs], + max_vm_pu=limits.max_vm_pu.loc[idxs], min_vm_pu=limits.min_vm_pu.loc[idxs]) + + # add impedances + rft_pu_g, xft_pu_g = adapt_impedance_params(Z[elm + "_ground"].loc[idxs].values) + max_idx = net_zpbn.impedance.index.max() if net_zpbn.impedance.shape[0] else 0 + new_imps_g = pd.DataFrame({ + "from_bus": Z.ext_bus.loc[idxs].astype(np.int64).values, "to_bus": new_g_buses, + "rft_pu": rft_pu_g, "xft_pu": xft_pu_g, + "rtf_pu": rft_pu_g, "xtf_pu": xft_pu_g, + "gf_pu": 0, "bf_pu": 0, "gt_pu": 0, "bt_pu": 0}, + index=range(max_idx + 1, max_idx + 1 + len(new_g_buses))) + new_imps_g["name"] = "eq_impedance_ext_to_ground" + new_imps_g["sn_mva"] = sn_mva + new_imps_g["in_service"] = True + + rft_pu_t, xft_pu_t = adapt_impedance_params(Z[elm + "_separate_total"].loc[ + idxs].values) + new_imps_t = pd.DataFrame({ + "from_bus": new_g_buses, "to_bus": new_t_buses, + "rft_pu": rft_pu_t, "xft_pu": xft_pu_t, + "rtf_pu": rft_pu_t, "xtf_pu": xft_pu_t, + "gf_pu": 0, "bf_pu": 0, "gt_pu": 0, "bt_pu": 0}, + index=range(new_imps_g.index.max() + 1, + new_imps_g.index.max() + 1 + len(new_g_buses))) + new_imps_t["name"] = "eq_impedance_ground_to_total" + new_imps_t["sn_mva"] = sn_mva + new_imps_t["in_service"] = True + + net_zpbn["impedance"] = pd.concat([net_zpbn["impedance"], new_imps_g, new_imps_t]) + g_buses += list(new_g_buses) + t_buses += list(new_t_buses) + continue + Z = Z.drop([elm + "_separate_total"], axis=1) + vn_kv = net_zpbn.bus.vn_kv[all_external_buses].values[0] + new_g_bus = create_bus(net_zpbn, vn_kv, name=elm + "_integrated-ground ") + i_all_integrated = [] + for i in Z.index[~np.isnan(Z[elm + "_ground"].values)]: + rft_pu, xft_pu = adapt_impedance_params(Z[elm + "_ground"][i]) + create_impedance(net_zpbn, Z.ext_bus[i], new_g_bus, rft_pu, xft_pu, + sn_mva, name="eq_impedance_ext_to_ground") + i_all_integrated.append(i) + # in case of integrated, the tightest vm limits are assumed + ext_buses = Z.ext_bus[~np.isnan(Z[elm + "_ground"])].values + ext_buses_name = "/".join([str(eb) for eb in ext_buses]) + new_t_bus = create_bus( + net_zpbn, vn_kv, name=elm + "_integrated-total " + ext_buses_name, + max_vm_pu=limits.max_vm_pu.loc[i_all_integrated].min(), + min_vm_pu=limits.min_vm_pu.loc[i_all_integrated].max()) + rft_pu, xft_pu = adapt_impedance_params(Z[elm + "_integrated_total"][0]) + create_impedance(net_zpbn, new_g_bus, new_t_bus, rft_pu, xft_pu, + sn_mva, name="eq_impedance_ground_to_total") + g_buses += [new_g_bus.tolist()] + t_buses += [new_t_bus.tolist()] # --- create load, sgen and gen elm_old = None max_load_idx = max(-1, net.load.index[~net.load.bus.isin(all_external_buses)].max() - len(net_zpbn.load)) @@ -335,20 +338,23 @@ def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses net_zpbn[elm].loc[elm_idx, ext_grid_cols] = net.ext_grid[ext_grid_cols][ net.ext_grid.bus == bus].values[0] else: - names = elm_org.name[elm_org.bus == bus].values - names = [str(n) for n in names] - net_zpbn[elm].loc[elm_idx, "name"] = "//".join(names) + "-" + net_zpbn[elm].name[elm_idx] + if "name" not in elm_org.columns: + names = [""] * elm_org.shape[1] + else: + names = elm_org.name[elm_org.bus == bus].values + names = [str(n) for n in names] + net_zpbn[elm].loc[elm_idx, "name"] = f'{"//".join(names)}-{net_zpbn[elm].name[elm_idx]}' if len(names) > 1: - net_zpbn[elm].loc[elm_idx, list(other_cols_number)] = \ + net_zpbn[elm].loc[elm_idx, list(other_cols_number)] = ( elm_org[list(other_cols_number)][elm_org.bus == bus].sum(axis=0) + ) if "voltLvl" in other_cols_number: - net_zpbn[elm].loc[elm_idx, "voltLvl"] = \ - net_zpbn.bus.voltLvl[boundary_buses].max() - net_zpbn[elm].loc[elm_idx, list(other_cols_bool)] = \ - elm_org[list(other_cols_bool)][elm_org.bus == bus].values.sum(axis=0) > 0 + net_zpbn[elm].loc[elm_idx, "voltLvl"] = net_zpbn.bus.voltLvl[boundary_buses].max() + for col in other_cols_bool: + col_values = elm_org.loc[elm_org.bus == bus, col] + net_zpbn[elm].loc[elm_idx, col] = float('nan') if col_values.isna().any() else col_values.any() - all_str_values = list(zip(*elm_org[list(other_cols_str)] \ - [elm_org.bus == bus].values[::-1])) + all_str_values = list(zip(*elm_org[list(other_cols_str)][elm_org.bus == bus].values[::-1])) for asv, colid in zip(all_str_values, other_cols_str): if len(set(asv)) == 1: net_zpbn[elm].loc[elm_idx, colid] = asv[0] @@ -359,27 +365,23 @@ def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses net_zpbn[elm][ocm] = net_zpbn[elm][ocm].astype("object") net_zpbn[elm].loc[elm_idx, list(other_cols_mixed)] = "mixed data type" else: - net_zpbn[elm].loc[elm_idx, list(other_cols_bool | other_cols_number | - other_cols_str | other_cols_none)] = \ - elm_org[list(other_cols_bool | other_cols_number | - other_cols_str | other_cols_none)][ - elm_org.bus == bus].values[0] - net_zpbn[elm].loc[elm_idx, list(other_cols)] = elm_org[list(other_cols)][ - elm_org.bus == bus].values[0] + columns = list(other_cols_bool | other_cols_number | other_cols_str | other_cols_none) + other_cols = list(other_cols) + net_zpbn[elm].loc[elm_idx, columns] = elm_org[columns][elm_org.bus == bus].values[0] + net_zpbn[elm].loc[elm_idx, other_cols] = elm_org[other_cols][elm_org.bus == bus].values[0] elm_old = net_zpbn.bus.name[i].split("_")[0] # --- match poly_cost to new created elements for cost_elm in ["poly_cost", "pwl_cost"]: if len(net[cost_elm]): df = net_zpbn[cost_elm].copy() - df.loc[(df.et == "ext_grid") & - (~df.bus.isin(boundary_buses)), 'et'] = "gen" - df.loc[(df.et.isin(["storage", "dcline"]) & - (~df.bus.isin(boundary_buses))), 'et'] = "load" + df.loc[(df.et == "ext_grid") & (~df.bus.isin(boundary_buses)), 'et'] = "gen" + df.loc[(df.et.isin(["storage", "dcline"]) & (~df.bus.isin(boundary_buses))), 'et'] = "load" - logger.debug("During the equivalencing, also in polt_cost, " + - "storages and dclines are treated as loads, and" + - "ext_grids are treated as gens ") + logger.debug( + "During the equivalencing, also in polt_cost, storages and dclines are treated as loads, " + "and ext_grids are treated as gens" + ) for elm in ["load", "gen", "sgen"]: for idx in net_zpbn[elm].index: @@ -414,8 +416,7 @@ def _create_net_zpbn(net, boundary_buses, all_internal_buses, all_external_buses df.loc[pc_idx[0], 'element'] = idx net_zpbn[cost_elm] = df - drop_and_edit_cost_functions(net_zpbn, [], False, True, False) - # pp.runpp(net_zpbn) + drop_and_edit_cost_functions(net_zpbn, [], False, True) runpp_fct(net_zpbn, calculate_voltage_angles=calc_volt_angles, tolerance_mva=1e-3, max_iteration=100, **kwargs) return net_zpbn, net_internal, net_external @@ -530,7 +531,7 @@ def _get_internal_and_external_nets(net, boundary_buses, all_internal_buses, net_internal = None else: net_internal = deepcopy(net) - drop_measurements_and_controllers(net_internal, all_external_buses, True) + drop_measurements_and_controllers(net_internal, all_external_buses) drop_and_edit_cost_functions(net_internal, all_external_buses + boundary_buses, True, True) drop_buses(net_internal, all_external_buses) @@ -543,10 +544,10 @@ def _get_internal_and_external_nets(net, boundary_buses, all_internal_buses, replace_motor_by_load(net_external, all_external_buses) # add_ext_grids_to_boundaries(net_external, boundary_buses, runpp_fct=runpp_fct, **kwargs) # runpp_fct(net_external, calculate_voltage_angles=calc_volt_angles, **kwargs) - + # for sgens, gens, and loads: _integrate_power_elements_connected_with_switch_buses(net, net_external, all_external_buses) - + runpp_fct(net_external, calculate_voltage_angles=calc_volt_angles, **kwargs) t_end = time.perf_counter() if show_computing_time: diff --git a/pandapower/grid_equivalents/toolbox.py b/pandapower/grid_equivalents/toolbox.py index f2c931344d..860675dc8f 100644 --- a/pandapower/grid_equivalents/toolbox.py +++ b/pandapower/grid_equivalents/toolbox.py @@ -14,54 +14,35 @@ logger = logging.getLogger(__name__) -def getFromDict(dict_, keys): - """ Get value from nested dict """ - return reduce(operator.getitem, keys, dict_) - - -def setInDict(dict_, keys, value): - """ Set value to nested dict """ - getFromDict(dict_, keys[:-1])[keys[-1]] = value - - -def appendSetInDict(dict_, keys, set_): - """ Use case specific: append existing value of type set in nested dict """ - getFromDict(dict_, keys[:-1])[keys[-1]] |= set_ - - -def setSetInDict(dict_, keys, set_): - """ Use case specific: set new or append existing value of type set in nested dict """ - if isinstance(getFromDict(dict_, keys[:-1]), dict): - if keys[-1] in getFromDict(dict_, keys[:-1]).keys(): - if isinstance(getFromDict(dict_, keys), set): - appendSetInDict(dict_, keys, set_) - else: - raise ValueError("The set in the nested dict cannot be appended since it actually " - "is not a set but a " + str(type(getFromDict(dict_, keys)))) - else: - setInDict(dict_, keys, set_) - else: - raise ValueError("This function expects a dict for 'getFromDict(dict_, " + str(keys[:-1]) + - ")', not a" + str(type(getFromDict(dict_, keys[:-1])))) - - def append_set_to_dict(dict_, set_, keys): - """ Appends a nested dict by the values of a set, independant if the keys already exist or not. + """ + Set ``set_`` in a nested dictionary ``dict_`` using the sequence ``keys`` + + Parameters: + dict_: The dictionary to modify (will update in place). + set_: The value to store at the deepest level. + keys: A sequence of keys that defines the nesting path. + + Raises: + ValueError: if a key (except the last) exists but is not a dictionary. """ keys = ensure_iterability(keys) # ensure that the dict way to the last key exist - for pos, _ in enumerate(keys[:-1]): - if isinstance(getFromDict(dict_, keys[:pos]), dict): - if keys[pos] not in getFromDict(dict_, keys[:pos]).keys(): - setInDict(dict_, keys[:pos + 1], dict()) - else: - raise ValueError("This function expects a dict for 'getFromDict(dict_, " + - str(keys[:pos]) + ")', not a" + str(type(getFromDict( - dict_, keys[:pos])))) - + current = dict_ + for k in keys[:-1]: + if k not in current: + current[k] = {} + elif not isinstance(current.get(k), dict): + raise ValueError(f"This function expects a dict for '{k}', not a {type(current[k])}") + current = current[k] # set the value - setSetInDict(dict_, keys, set_) + if keys[-1] not in current: + current[keys[-1]] = set_ + elif isinstance(current[keys[-1]], set): + current[keys[-1]].update(set_) + else: + raise ValueError(f"This function expects a set for '{keys[-1]}', not a {type(current[keys[-1]])}") def set_bus_zone_by_boundary_branches(net, all_boundary_branches): @@ -75,7 +56,7 @@ def set_bus_zone_by_boundary_branches(net, all_boundary_branches): **all_boundary_branches** (dict) - defines which element indices are boundary branches. The dict keys must be pandapower elements, e.g. "line" or "trafo" """ - include = dict.fromkeys(["line", "trafo", "trafo3w", "impedance"]) + include = {"line": None, "trafo": None, "trafo3w": None, "impedance": None} for elm in include.keys(): if elm in all_boundary_branches.keys(): include[elm] = net[elm].index.difference(all_boundary_branches[elm]) @@ -101,9 +82,9 @@ def set_bus_zone_by_boundary_branches(net, all_boundary_branches): for i, set_ in enumerate(ccl): if set_.intersection(areas[-1]): areas[-1] |= ccl.pop(i) - + for i, area in enumerate(areas): - net.bus.loc[list(area), "zone"] = i + net.bus.loc[list(area), "zone"] = str(i) def get_boundaries_by_bus_zone_with_boundary_branches(net): @@ -175,11 +156,9 @@ def append_boundary_buses_externals_per_zone(boundary_buses, boundaries, zone, o branch_dict[elm] += [bus] zones = net.bus.zone.unique() - boundary_branches = {zone if net.bus.zone.dtype == object else zone.item(): - dict() for zone in zones} - boundary_branches["all"] = dict() - boundary_buses = {zone if net.bus.zone.dtype == object else zone.item(): - {"all": set(), "internal": set(), "external": set()} for zone in zones} + boundary_branches = {zone: {} for zone in zones} + boundary_branches["all"] = {} + boundary_buses = {zone: {"all": set(), "internal": set(), "external": set()} for zone in zones} boundary_buses["all"] = set() for elm, buses in branch_dict.items(): diff --git a/pandapower/groups.py b/pandapower/groups.py index 7f68dd2f12..bb8136514c 100644 --- a/pandapower/groups.py +++ b/pandapower/groups.py @@ -930,27 +930,18 @@ def group_res_q_mvar(net, index): def set_group_reference_column(net, index, reference_column, element_type=None): - """Set a reference_column to the group of given index. The values in net.group.element get - updated. - - Parameters - ---------- - net : pandapowerNet - pandapower net - index : int - Index of the group - reference_column : str - column in the elemt tables which should be used as reference to link the group members. - If no column but the index should be used as the link (is the default), reference_column - should be None. - element_type : str, optional - Type of element which should get a new column to reference. If None, all element types are - considered, by default None - - Raises - ------ - ValueError - net[element_type][reference_column] has duplicated values. + """Set a reference_column to the group of given index. The values in net.group.element get updated. + + Parameters: + net: pandapower net + index: Index of the group + reference_column: column in the element tables which should be used as reference to link the group members. + If no column but the index should be used as the link (is the default), reference_column should be None. + element_type: Type of element which should get a new column to reference. If None, all element types are + considered, by default None + + Raises: + ValueError: if net[element_type][reference_column] has duplicated values. """ if element_type is None: element_type = net.group.loc[[index], "element_type"].tolist() @@ -959,23 +950,20 @@ def set_group_reference_column(net, index, reference_column, element_type=None): dupl_elements = [] for et in element_type: - if reference_column is None: # determine duplicated indices which would corrupt Groups functionality if len(set(net[et].index)) != net[et].shape[0]: dupl_elements.append(et) - else: # fill nan values in net[et][reference_column] with unique names if reference_column not in net[et].columns: - net[et][reference_column] = pd.Series([None]*net[et].shape[0], dtype=object) - if pd.api.types.is_object_dtype(net[et][reference_column]): - idxs = net[et].index[net[et][reference_column].isnull()] - net[et].loc[idxs, reference_column] = ["%s_%i_%s" % (et, idx, str( - uuid.uuid4())) for idx in idxs] + net[et][reference_column] = pd.Series(None, dtype=object) + if net[et][reference_column].isna().any(): + idxs = net[et].index[net[et][reference_column].isna()] + net[et].loc[idxs, reference_column] = [f"{et}_{idx}_{uuid.uuid4()}" for idx in idxs] # determine duplicated values which would corrupt Groups functionality - if (net[et][reference_column].duplicated() | net[et][reference_column].isnull()).any(): + if (net[et][reference_column].duplicated() | net[et][reference_column].isna()).any(): dupl_elements.append(et) # update net.group[["element_index", "reference_column"]] for element_type == et @@ -997,11 +985,8 @@ def set_group_reference_column(net, index, reference_column, element_type=None): if len(dupl_elements): if reference_column is None: - raise ValueError(f"In net[*].index have duplicated or nan values. " - f"* is placeholder for {dupl_elements}.") - else: - raise ValueError(f"In net[*].{reference_column} have duplicated or nan values. " - f"* is placeholder for {dupl_elements}.") + reference_column = 'index' + raise ValueError(f"In tables {dupl_elements} column {reference_column} has duplicate or nan values.") def return_group_as_net(net, index, keep_everything_else=False, verbose=True, **kwargs): diff --git a/pandapower/io_utils.py b/pandapower/io_utils.py index 99e8291718..996d5f1cb2 100644 --- a/pandapower/io_utils.py +++ b/pandapower/io_utils.py @@ -285,6 +285,7 @@ def from_dict_of_dfs(dodfs, net=None): # convert geodata to geojson if item in ["bus", "line"]: if "geo" in table.columns: + table.geo = table.geo.mask(table.geo == '""', pd.NA) table.geo = table.geo.apply( lambda x: geojson.loads(x, cls=PPJSONDecoder) if pd.notna(x) else x ) @@ -764,12 +765,12 @@ def pp_hook( omit_modules=None ): try: - if not omit_tables is None: + if omit_tables is not None: for ot in omit_tables: if ot in d: d[ot].drop(d[ot].index, inplace=True) if '_module' in d and '_class' in d: - if not omit_modules is None: + if omit_modules is not None: for om in omit_modules: if om in d['_module']: return diff --git a/pandapower/network_schema/__init__.py b/pandapower/network_schema/__init__.py index 0d306c5f4b..54a517b4b2 100644 --- a/pandapower/network_schema/__init__.py +++ b/pandapower/network_schema/__init__.py @@ -8,7 +8,7 @@ res_asymmetric_sgen_schema, res_asymmetric_sgen_3ph_schema, ) -from pandapower.network_schema.vsc_schema import vsc_stacked_schema, res_vsc_stacked_schema +from pandapower.network_schema.vsc_stacked import vsc_stacked_schema, res_vsc_stacked_schema from pandapower.network_schema.vsc_bipolar import vsc_bipolar_schema, res_vsc_bipolar_schema from pandapower.network_schema.bus import bus_schema, res_bus_schema, res_bus_3ph_schema from pandapower.network_schema.bus_dc import bus_dc_schema, res_bus_dc_schema diff --git a/pandapower/network_schema/asymmetric_load.py b/pandapower/network_schema/asymmetric_load.py index 9c411b8b8d..9c94952e0a 100644 --- a/pandapower/network_schema/asymmetric_load.py +++ b/pandapower/network_schema/asymmetric_load.py @@ -7,18 +7,43 @@ "bus": pa.Column( int, pa.Check.ge(0), description=" index of connected bus", metadata={"foreign_key": "bus.index"} ), - "p_a_mw": pa.Column(float, pa.Check.ge(0), description="Phase A active power of the load [MW]"), - "p_b_mw": pa.Column(float, pa.Check.ge(0), description="Phase B active power of the load [MW]"), - "p_c_mw": pa.Column(float, pa.Check.ge(0), description="Phase C active power of the load [MW]"), - "q_a_mvar": pa.Column(float, description="Phase A reactive power of the load [MVar]"), - "q_b_mvar": pa.Column(float, description="Phase B reactive power of the load [MVar]"), - "q_c_mvar": pa.Column(float, description="Phase C reactive power of the load [MVar]"), + "p_a_mw": pa.Column( + float, pa.Check.ge(0), description="Phase A active power of the load [MW]", metadata={"default": 0.0} + ), + "p_b_mw": pa.Column( + float, pa.Check.ge(0), description="Phase B active power of the load [MW]", metadata={"default": 0.0} + ), + "p_c_mw": pa.Column( + float, pa.Check.ge(0), description="Phase C active power of the load [MW]", metadata={"default": 0.0} + ), + "q_a_mvar": pa.Column( + float, description="Phase A reactive power of the load [MVar]", metadata={"default": 0.0} + ), + "q_b_mvar": pa.Column( + float, description="Phase B reactive power of the load [MVar]", metadata={"default": 0.0} + ), + "q_c_mvar": pa.Column( + float, description="Phase C reactive power of the load [MVar]", metadata={"default": 0.0} + ), "sn_mva": pa.Column( float, pa.Check.gt(0), nullable=True, required=False, description="rated power of the load [MVA]" ), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for active and reactive power"), - "in_service": pa.Column(bool, description="specifies if the load is in service."), - "type": pa.Column(str, pa.Check.isin(["wye", "delta"]), description="type of load"), + "sn_a_mva": pa.Column( + float, pa.Check.gt(0), nullable=True, required=False, description="Phase A rated power of the load [MVA]" + ), + "sn_b_mva": pa.Column( + float, pa.Check.gt(0), nullable=True, required=False, description="Phase B rated power of the load [MVA]" + ), + "sn_c_mva": pa.Column( + float, pa.Check.gt(0), nullable=True, required=False, description="Phase C rated power of the load [MVA]" + ), + "scaling": pa.Column( + float, pa.Check.ge(0), description="scaling factor for active and reactive power", metadata={"default": 1.0} + ), + "in_service": pa.Column(bool, description="specifies if the load is in service.", metadata={"default": True}), + "type": pa.Column( + str, pa.Check.isin(["wye", "delta"]), description="type of load", metadata={"default": "wye"} + ), }, strict=False, ) @@ -65,12 +90,12 @@ res_asymmetric_load_3ph_schema = pa.DataFrameSchema( { - "p_a_mw": pa.Column(float, nullable=True, description=""), # not in docu - "q_a_mvar": pa.Column(float, nullable=True, description=""), # not in docu - "p_b_mw": pa.Column(float, nullable=True, description=""), # not in docu - "q_b_mvar": pa.Column(float, nullable=True, description=""), # not in docu - "p_c_mw": pa.Column(float, nullable=True, description=""), # not in docu - "q_c_mvar": pa.Column(float, nullable=True, description=""), # not in docu + "p_a_mw": pa.Column(float, nullable=True, description=""), # TODO: not in docu + "q_a_mvar": pa.Column(float, nullable=True, description=""), # TODO: not in docu + "p_b_mw": pa.Column(float, nullable=True, description=""), # TODO: not in docu + "q_b_mvar": pa.Column(float, nullable=True, description=""), # TODO: not in docu + "p_c_mw": pa.Column(float, nullable=True, description=""), # TODO: not in docu + "q_c_mvar": pa.Column(float, nullable=True, description=""), # TODO: not in docu }, strict=False, ) diff --git a/pandapower/network_schema/asymmetric_sgen.py b/pandapower/network_schema/asymmetric_sgen.py index 3a65c66d41..f7bd1d85f6 100644 --- a/pandapower/network_schema/asymmetric_sgen.py +++ b/pandapower/network_schema/asymmetric_sgen.py @@ -6,20 +6,41 @@ "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the static generator"), "type": pa.Column( pd.StringDtype, - # pa.Check.isin(["PV", "WP", "CHP"]), nullable=True, required=False, description="type of generator", + metadata={"default": "wye"}, ), "bus": pa.Column( int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"} ), - "p_a_mw": pa.Column(float, pa.Check.le(0), description="active power of the static generator : Phase A[MW]"), - "q_a_mvar": pa.Column(float, description="reactive power of the static generator : Phase A [MVar]"), - "p_b_mw": pa.Column(float, pa.Check.le(0), description="active power of the static generator : Phase B [MW]"), - "q_b_mvar": pa.Column(float, description="reactive power of the static generator : Phase B [MVar]"), - "p_c_mw": pa.Column(float, pa.Check.le(0), description="active power of the static generator : Phase C [MW]"), - "q_c_mvar": pa.Column(float, description="reactive power of the static generator : Phase C [MVar]"), + "p_a_mw": pa.Column( + float, + pa.Check.le(0), + description="active power of the static generator : Phase A[MW]", + metadata={"default": 0.0}, + ), + "q_a_mvar": pa.Column( + float, description="reactive power of the static generator : Phase A [MVar]", metadata={"default": 0.0} + ), + "p_b_mw": pa.Column( + float, + pa.Check.le(0), + description="active power of the static generator : Phase B [MW]", + metadata={"default": 0.0}, + ), + "q_b_mvar": pa.Column( + float, description="reactive power of the static generator : Phase B [MVar]", metadata={"default": 0.0} + ), + "p_c_mw": pa.Column( + float, + pa.Check.le(0), + description="active power of the static generator : Phase C [MW]", + metadata={"default": 0.0}, + ), + "q_c_mvar": pa.Column( + float, description="reactive power of the static generator : Phase C [MVar]", metadata={"default": 0.0} + ), "sn_mva": pa.Column( float, pa.Check.gt(0), @@ -27,8 +48,36 @@ required=False, description="rated power ot the static generator [MVA]", ), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for the active and reactive power"), - "in_service": pa.Column(bool, description="specifies if the generator is in service."), + "sn_a_mva": pa.Column( + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="Phase A rated power ot the static generator [MVA]", + ), + "sn_b_mva": pa.Column( + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="Phase B rated power ot the static generator [MVA]", + ), + "sn_c_mva": pa.Column( + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="Phase C rated power ot the static generator [MVA]", + ), + "scaling": pa.Column( + float, + pa.Check.ge(0), + description="scaling factor for the active and reactive power", + metadata={"default": 1.0}, + ), + "in_service": pa.Column( + bool, description="specifies if the generator is in service.", metadata={"default": True} + ), "current_source": pa.Column(bool, description=""), # TODO: missing in docu }, strict=False, diff --git a/pandapower/network_schema/bus.py b/pandapower/network_schema/bus.py index 6cfd5689db..6d01694d79 100644 --- a/pandapower/network_schema/bus.py +++ b/pandapower/network_schema/bus.py @@ -7,27 +7,140 @@ _bus_columns = { "name": pa.Column(pd.StringDtype, nullable=True, required=True, description="name of the bus"), "vn_kv": pa.Column(float, pa.Check.gt(0), description="rated voltage of the bus [kV]"), - "type": pa.Column(pd.StringDtype, nullable=True, required=False, description="type variable to classify buses"), + "type": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="type variable to classify buses", + metadata={"cim": True, "default": "b"}, + ), "zone": pa.Column( pd.StringDtype, nullable=True, required=False, description="can be used to group buses, for example network groups / regions", + metadata={"cim": True}, ), "max_vm_pu": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="Maximum voltage", metadata={"opf": True} + float, + pa.Check.gt(0), + nullable=False, + required=False, + description="Maximum voltage", + metadata={"opf": True, "default": 2.0}, ), "min_vm_pu": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="Minimum voltage", metadata={"opf": True} + float, + pa.Check.ge(0), + nullable=False, + required=False, + description="Minimum voltage", + metadata={"opf": True, "default": 0.0}, + ), + "in_service": pa.Column(bool, description="specifies if the bus is in service.", metadata={"default": True}), + "geo": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="geojson.Point as object or string", + metadata={"cim": True}, + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "origin_profile": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_profile from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "cim_topnode": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="cim_topnode from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "ConnectivityNodeContainer_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="ConnectivityNodeContainer_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "Substation_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="Substation_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "Busbar_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="Busbar_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "Busbar_name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="Busbar_name from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "GeographicalRegion_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="GeographicalRegion_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "GeographicalRegion_name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="GeographicalRegion_name from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "SubGeographicalRegion_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="SubGeographicalRegion_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "SubGeographicalRegion_name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="SubGeographicalRegion_name from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "ucte_country": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="ucte_country from converter, not relevant for calculations", + metadata={"ucte": True}, ), - "in_service": pa.Column(bool, description="specifies if the bus is in service."), - "geo": pa.Column(pd.StringDtype, nullable=True, required=False, description="geojson.Point as object or string"), } bus_schema = pa.DataFrameSchema( _bus_columns, checks=[ + *create_column_dependency_checks_from_metadata(["opf"], _bus_columns), create_lower_equals_column_check(first_element="min_vm_pu", second_element="max_vm_pu"), - create_column_dependency_checks_from_metadata(["opf"], _bus_columns), ], strict=False, ) diff --git a/pandapower/network_schema/bus_dc.py b/pandapower/network_schema/bus_dc.py index 95ebe20adb..e90a888724 100644 --- a/pandapower/network_schema/bus_dc.py +++ b/pandapower/network_schema/bus_dc.py @@ -6,20 +6,34 @@ _bus_dc_columns = { "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the dc bus"), "vn_kv": pa.Column(float, pa.Check.gt(0), description="rated voltage of the dc bus [kV]"), - "type": pa.Column(pd.StringDtype, nullable=True, required=False, description="type variable to classify buses"), + "type": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="type variable to classify buses", + metadata={"default": "b"}, + ), "zone": pa.Column( pd.StringDtype, nullable=True, required=False, description="can be used to group dc buses, for example network groups / regions", ), - "in_service": pa.Column(bool, description="specifies if the dc bus is in service"), + "in_service": pa.Column(bool, description="specifies if the dc bus is in service", metadata={"default": True}), "geo": pa.Column(pd.StringDtype, nullable=True, required=False, description="geojson.Point as object or string"), "max_vm_pu": pa.Column( - float, description="Maximum dc bus voltage in p.u. - necessary for OPF", metadata={"opf": True} + float, + nullable=True, + required=False, + description="Maximum dc bus voltage in p.u. - necessary for OPF", + metadata={"opf": True, "default": 2.0}, ), "min_vm_pu": pa.Column( - float, description="Minimum dc bus voltage in p.u. - necessary for OPF", metadata={"opf": True} + float, + nullable=True, + required=False, + description="Minimum dc bus voltage in p.u. - necessary for OPF", + metadata={"opf": True, "default": 0.0}, ), } bus_dc_schema = pa.DataFrameSchema( diff --git a/pandapower/network_schema/dcline.py b/pandapower/network_schema/dcline.py index 77834de7b0..0d9595ed8c 100644 --- a/pandapower/network_schema/dcline.py +++ b/pandapower/network_schema/dcline.py @@ -4,7 +4,9 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _dcline_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the generator"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the generator", metadata={"cim": True} + ), "from_bus": pa.Column( int, pa.Check.ge(0), @@ -31,7 +33,7 @@ nullable=True, required=False, description="Maximum active power transmission", - metadata={"opf": True}, + metadata={"opf": True, "cim": True}, ), "min_p_mw": pa.Column( float, @@ -45,22 +47,57 @@ nullable=True, required=False, description="Minimum reactive power at from bus", - metadata={"opf": True}, + metadata={"opf": True, "cim": True}, ), "max_q_from_mvar": pa.Column( float, nullable=True, required=False, description="Maximum reactive power at from bus", - metadata={"opf": True}, + metadata={"opf": True, "cim": True}, ), "min_q_to_mvar": pa.Column( - float, nullable=True, required=False, description="Minimum reactive power at to bus", metadata={"opf": True} + float, + nullable=True, + required=False, + description="Minimum reactive power at to bus", + metadata={"opf": True, "cim": True}, ), "max_q_to_mvar": pa.Column( - float, nullable=True, required=False, description="Maximum reactive power at to bus", metadata={"opf": True} + float, + nullable=True, + required=False, + description="Maximum reactive power at to bus", + metadata={"opf": True, "cim": True}, + ), + "in_service": pa.Column(bool, description="specifies if the line is in service.", metadata={"default": True}), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_to": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_to from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_from": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_from from converter, not relevant for calculations", + metadata={"cim": True}, ), - "in_service": pa.Column(bool, description="specifies if the line is in service."), } dcline_schema = pa.DataFrameSchema( _dcline_columns, diff --git a/pandapower/network_schema/ext_grid.py b/pandapower/network_schema/ext_grid.py index d24755800a..6ce28aa3a2 100644 --- a/pandapower/network_schema/ext_grid.py +++ b/pandapower/network_schema/ext_grid.py @@ -4,21 +4,23 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _ext_grid_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the external grid"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the external grid", metadata={"cim": True} + ), "bus": pa.Column(int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"}), - "vm_pu": pa.Column(float, pa.Check.gt(0), description="voltage set point [p.u]"), - "va_degree": pa.Column(float, description="voltage angle set point [degree]"), + "vm_pu": pa.Column(float, pa.Check.gt(0), description="voltage set point [p.u]", metadata={"default": 1.0}), + "va_degree": pa.Column(float, description="voltage angle set point [degree]", metadata={"default": 0.0}), "max_p_mw": pa.Column( - float, nullable=True, required=False, description="Maximum active power", metadata={"opf": True} + float, nullable=True, required=False, description="Maximum active power", metadata={"opf": True, "cim": True} ), "min_p_mw": pa.Column( - float, nullable=True, required=False, description="Minimum active power", metadata={"opf": True} + float, nullable=True, required=False, description="Minimum active power", metadata={"opf": True, "cim": True} ), "max_q_mvar": pa.Column( - float, nullable=True, required=False, description="Maximum reactive power", metadata={"opf": True} + float, nullable=True, required=False, description="Maximum reactive power", metadata={"opf": True, "cim": True} ), "min_q_mvar": pa.Column( - float, nullable=True, required=False, description="Minimum reactive power", metadata={"opf": True} + float, nullable=True, required=False, description="Minimum reactive power", metadata={"opf": True, "cim": True} ), "s_sc_max_mva": pa.Column( float, @@ -26,7 +28,7 @@ nullable=True, required=False, description="maximum short circuit power provision [MVA]", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "s_sc_min_mva": pa.Column( float, @@ -34,7 +36,7 @@ nullable=True, required=False, description="minimum short circuit power provision [MVA]", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "rx_max": pa.Column( float, @@ -42,7 +44,7 @@ nullable=True, required=False, description="maxium R/X ratio of short-circuit impedance", - metadata={"sc": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "rx_min": pa.Column( float, @@ -50,7 +52,7 @@ nullable=True, required=False, description="minimum R/X ratio of short-circuit impedance", - metadata={"sc": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "r0x0_max": pa.Column( float, @@ -58,7 +60,7 @@ nullable=True, required=False, description="maximal R/X-ratio to calculate Zero sequence internal impedance of ext_grid", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "x0x_max": pa.Column( float, @@ -66,15 +68,81 @@ nullable=True, required=False, description="maximal X0/X-ratio to calculate Zero sequence internal impedance of ext_grid", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "slack_weight": pa.Column( - float, description="Contribution factor for distributed slack power flow calculation (active power balancing)" + float, + description="Contribution factor for distributed slack power flow calculation (active power balancing)", + metadata={"default": 1.0}, + ), + "in_service": pa.Column( + bool, description="specifies if the external grid is in service.", metadata={"default": True} ), - "in_service": pa.Column(bool, description="specifies if the external grid is in service."), "controllable": pa.Column( bool, description="Control of value limits - True: p_mw, q_mvar and vm_pu limits are enforced for the ext_grid in OPF. The voltage limits set in the ext_grid bus are enforced. - False: p_mw and vm_pu set points are enforced and *limits are ignored*. The vm_pu set point is enforced and limits of the bus table are ignored. Defaults to False if controllable column exists in DataFrame", + metadata={"default": False}, + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "substation": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="substation from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.mode": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="RegulatingControl.mode from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.targetValue": pa.Column( + float, + nullable=True, + required=False, + description="RegulatingControl.targetValue from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.enabled": pa.Column( + float, + nullable=True, + required=False, + description="RegulatingControl.enabled from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "referencePriority": pa.Column( + float, + nullable=True, + required=False, + description="referencePriority, from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "p_mw": pa.Column( + float, nullable=True, description="p from converter, not relevant for calculations", metadata={"cim": True} + ), + "q_mvar": pa.Column( + float, nullable=True, description="q from converter, not relevant for calculations", metadata={"cim": True} ), } ext_grid_schema = pa.DataFrameSchema( diff --git a/pandapower/network_schema/gen.py b/pandapower/network_schema/gen.py index 9bae1d5c2d..50ca91c111 100644 --- a/pandapower/network_schema/gen.py +++ b/pandapower/network_schema/gen.py @@ -1,49 +1,62 @@ import pandas as pd import pandera.pandas as pa +from pandapower.network_schema.tools.validation.column_condition import create_lower_equals_column_check from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _gen_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the generator"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the generator", metadata={"cim": True} + ), "type": pa.Column( pd.StringDtype, nullable=True, required=False, description="type variable to classify generators naming conventions: “sync” - synchronous generator “async” - asynchronous generator", + metadata={"cim": True}, ), "bus": pa.Column(int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"}), "p_mw": pa.Column(float, description="active power of the generator [MW]"), - "vm_pu": pa.Column(float, pa.Check.gt(0), description="voltage set point of the generator [p.u.]"), + "vm_pu": pa.Column( + float, pa.Check.gt(0), description="voltage set point of the generator [p.u.]", metadata={"default": 1.0} + ), "sn_mva": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="nominal power of the generator [MVA]" + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="nominal power of the generator [MVA]", + metadata={"cim": True}, ), "max_q_mvar": pa.Column( float, nullable=True, required=False, description="maximum reactive power of the generator [MVAr]", - metadata={"opf": True, "q_lim_enforced": True}, + metadata={"opf": True, "q_lim_enforced": True, "cim": True}, ), "min_q_mvar": pa.Column( float, nullable=True, required=False, description="minimum reactive power of the generator [MVAr]", - metadata={"opf": True, "q_lim_enforced": True}, + metadata={"opf": True, "q_lim_enforced": True, "cim": True}, ), "scaling": pa.Column( - float, - pa.Check.ge(0), - description="scaling factor for the active power", + float, pa.Check.ge(0), description="scaling factor for the active power", metadata={"default": 1.0} ), "max_p_mw": pa.Column( - float, nullable=True, required=False, description="maximum active power", metadata={"opf": True} + float, nullable=True, required=False, description="maximum active power", metadata={"opf": True, "cim": True} ), "min_p_mw": pa.Column( - float, nullable=True, required=False, description="minimum active power", metadata={"opf": True} + float, nullable=True, required=False, description="minimum active power", metadata={"opf": True, "cim": True} ), "vn_kv": pa.Column( - float, nullable=True, required=False, description="rated voltage of the generator", metadata={"sc": True} + float, + nullable=True, + required=False, + description="rated voltage of the generator", + metadata={"sc": True, "cim": True}, ), "xdss_pu": pa.Column( float, @@ -51,7 +64,7 @@ nullable=True, required=False, description="subtransient generator reactance in per unit", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "rdss_ohm": pa.Column( float, @@ -59,7 +72,7 @@ nullable=True, required=False, description="subtransient generator resistence in ohm", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "cos_phi": pa.Column( float, @@ -67,9 +80,9 @@ nullable=True, required=False, description="rated generator cosine phi", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), - "in_service": pa.Column(bool, description="specifies if the generator is in service"), + "in_service": pa.Column(bool, description="specifies if the generator is in service", metadata={"default": True}), "power_station_trafo": pa.Column( pd.Int64Dtype, nullable=True, @@ -82,7 +95,7 @@ nullable=True, required=False, description="references the index of the characteristic from the q_capability_characteristic", - metadata={"qcc": True}, + metadata={"qcc": True, "cim": True}, ), "curve_style": pa.Column( pd.StringDtype, @@ -90,28 +103,32 @@ nullable=True, required=False, description="the style of the generator reactive power capability curve", - metadata={"qcc": True}, + metadata={"qcc": True, "cim": True}, ), "reactive_capability_curve": pa.Column( - pd.BooleanDtype, - nullable=True, + bool, + nullable=False, required=False, description="True if generator has dependency on q characteristic", - metadata={"qcc": True}, + metadata={"qcc": True, "cim": True, "default": False}, ), "slack_weight": pa.Column( - float, nullable=True, required=False, description="weight of the slack when using multiple slacks" + float, + nullable=True, + required=False, + description="weight of the slack when using multiple slacks", + metadata={"cim": True}, ), - "slack": pa.Column(bool, description="use the gen as slack"), + "slack": pa.Column(bool, description="use the gen as slack", metadata={"default": False}), "controllable": pa.Column( - pd.BooleanDtype, nullable=True, required=False, description="allow control for opf", metadata={"opf": True} + bool, required=False, description="allow control for opf", metadata={"opf": True, "default": False} ), "pg_percent": pa.Column( float, nullable=True, required=False, description="Rated pg (voltage control range) of the generator for short-circuit calculation", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "min_vm_pu": pa.Column( float, @@ -119,7 +136,7 @@ nullable=True, required=False, description="Minimum voltage magnitude. If not set, the bus voltage limit is taken - necessary for OPF.", - metadata={"opf": True}, + metadata={"opf": True, "default": 0.0}, ), "max_vm_pu": pa.Column( float, @@ -127,21 +144,80 @@ nullable=True, required=False, description="Maximum voltage magnitude. If not set, the bus voltage limit is taken - necessary for OPF", - metadata={"opf": True}, + metadata={"opf": True, "default": 2.0}, + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.mode": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="RegulatingControl.mode from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "governorSCD": pa.Column( + float, + nullable=True, + required=False, + description="governorSCD from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.targetValue": pa.Column( + float, + nullable=True, + required=False, + description="RegulatingControl.targetValue from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "referencePriority": pa.Column( + float, + nullable=True, + required=False, + description="referencePriority from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.enabled": pa.Column( + pd.BooleanDtype, + nullable=True, + required=False, + description="RegulatingControl.enabled from converter, not relevant for calculations", + metadata={"cim": True}, ), } +gen_checks = create_column_dependency_checks_from_metadata( + [ + "opf", + # "sc", + "q_lim_enforced", + "qcc", + ], + _gen_columns, +) +gen_checks.append(create_lower_equals_column_check(first_element="min_q_mvar", second_element="max_q_mvar")) +gen_checks.append(create_lower_equals_column_check(first_element="min_p_mw", second_element="max_p_mw")) +gen_checks.append(create_lower_equals_column_check(first_element="min_vm_pu", second_element="max_vm_pu")) gen_schema = pa.DataFrameSchema( _gen_columns, strict=False, - checks=create_column_dependency_checks_from_metadata( - [ - "opf", - # "sc", - "q_lim_enforced", - "qcc", - ], - _gen_columns, - ), + checks=gen_checks, ) diff --git a/pandapower/network_schema/impedance.py b/pandapower/network_schema/impedance.py index 3b6e489c5f..fe96ebf56c 100644 --- a/pandapower/network_schema/impedance.py +++ b/pandapower/network_schema/impedance.py @@ -3,7 +3,9 @@ impedance_schema = pa.DataFrameSchema( { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the impedance"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the impedance", metadata={"cim": True} + ), "from_bus": pa.Column( int, pa.Check.ge(0), @@ -26,6 +28,7 @@ nullable=True, required=False, description="zero-sequence resistance of the impedance from ‘from’ to ‘to’ bus [p.u.]", + metadata={"cim": True}, ), "xft0_pu": pa.Column( float, @@ -33,6 +36,7 @@ nullable=True, required=False, description="zero-sequence reactance of the impedance from ‘from’ to ‘to’ bus [p.u.]", + metadata={"cim": True}, ), "rtf0_pu": pa.Column( float, @@ -40,6 +44,7 @@ nullable=True, required=False, description="zero-sequence resistance of the impedance from ‘to’ to ‘from’ bus [p.u.]", + metadata={"cim": True}, ), "xtf0_pu": pa.Column( float, @@ -47,16 +52,19 @@ nullable=True, required=False, description="zero-sequence reactance of the impedance from ‘to’ to ‘from’ bus [p.u.]", + metadata={"cim": True}, ), "gf_pu": pa.Column( float, # pa.Check.gt(1), description="conductance at the ‘from_bus’ [p.u.]", + metadata={"default": 0.0}, ), "bf_pu": pa.Column( float, # pa.Check.gt(2), description="susceptance at the ‘from_bus’ [p.u.]", + metadata={"default": 0.0}, ), "gt_pu": pa.Column( float, @@ -99,7 +107,40 @@ "sn_mva": pa.Column( float, pa.Check.gt(0), description="reference apparent power for the impedance per unit values [MVA]" ), - "in_service": pa.Column(bool, description="specifies if the impedance is in service."), + "in_service": pa.Column( + bool, description="specifies if the impedance is in service.", metadata={"default": True} + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_to": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_to from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_from": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_from from converter, not relevant for calculations", + metadata={"cim": True}, + ), }, strict=False, ) diff --git a/pandapower/network_schema/line.py b/pandapower/network_schema/line.py index 902e330c02..cf21319351 100644 --- a/pandapower/network_schema/line.py +++ b/pandapower/network_schema/line.py @@ -4,7 +4,13 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _line_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the line"), + "name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="name of the line", + metadata={"cim": True, "ucte": True}, + ), "std_type": pa.Column( pd.StringDtype, nullable=True, @@ -27,6 +33,7 @@ float, pa.Check.ge(0), description="dielectric conductance in micro Siemens per km", + metadata={"default": 0.0}, ), "r0_ohm_per_km": pa.Column( float, @@ -34,7 +41,7 @@ nullable=True, required=False, description="zero sequence resistance of the line [Ohm per km]", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "x0_ohm_per_km": pa.Column( float, @@ -42,7 +49,7 @@ nullable=True, required=False, description="zero sequence reactance of the line [Ohm per km]", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "c0_nf_per_km": pa.Column( float, @@ -50,7 +57,7 @@ nullable=True, required=False, description="zero sequence capacitance of the line [nano Farad per km]", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "g0_us_per_km": pa.Column( float, @@ -58,12 +65,15 @@ nullable=True, required=False, description="dielectric conductance of the line [micro Siemens per km]", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True, "default": 0.0}, ), "max_i_ka": pa.Column(float, pa.Check.gt(0), description="maximal thermal current [kilo Ampere]"), - "parallel": pa.Column(int, pa.Check.ge(1), description="number of parallel line systems"), + "parallel": pa.Column(int, pa.Check.ge(1), description="number of parallel line systems", metadata={"default": 1}), "df": pa.Column( - float, pa.Check.between(min_value=0, max_value=1), description="derating factor (scaling) for max_i_ka" + float, + pa.Check.between(min_value=0, max_value=1), + description="derating factor (scaling) for max_i_ka", + metadata={"default": 1.0}, ), "type": pa.Column( pd.StringDtype, @@ -85,14 +95,15 @@ nullable=True, required=False, description="Short-Circuit end temperature of the line in degree Celsius", - metadata={"sc": True, "tdpf": True}, + metadata={"sc": True, "tdpf": True, "cim": True}, ), - "in_service": pa.Column(bool, description="specifies if the line is in service."), + "in_service": pa.Column(bool, description="specifies if the line is in service.", metadata={"default": True}), "geo": pa.Column( pd.StringDtype, nullable=True, required=False, description="geojson.LineString object or its string representation", + metadata={"cim": True}, ), "alpha": pa.Column( float, @@ -187,6 +198,47 @@ description="specific mass of the conductor multiplied by the specific thermal capacity of the material (TDPF, only for thermal inertia consideration with tdpf_delay_s parameter)", metadata={"tdpf": True}, ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_to": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_to from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_from": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_from from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "EquipmentContainer_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="EquipmentContainer_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "amica_name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="amica_name from converter, not relevant for calculations", + metadata={"ucte": True}, + ), } line_schema = pa.DataFrameSchema( _line_columns, @@ -285,18 +337,14 @@ "i_c_from_ka": pa.Column(float, nullable=True, description="Current at from bus: Phase C [kA]"), "i_c_to_ka": pa.Column(float, nullable=True, description="Current at to bus: Phase C [kA]"), "i_c_ka": pa.Column(float, nullable=True, description="Current at Phase C [kA]"), - "i_n_from_ka": pa.Column( - float, nullable=True, description="Current at from bus: Neutral [kA]" - ), # TODO: muss mike schauen - "i_n_to_ka": pa.Column( - float, nullable=True, description="Current at to bus: Neutral [kA]" - ), # TODO: muss mike schauen + "i_n_from_ka": pa.Column(float, nullable=True, description="Current at from bus: Neutral [kA]"), + "i_n_to_ka": pa.Column(float, nullable=True, description="Current at to bus: Neutral [kA]"), "i_ka": pa.Column(float, nullable=True, description="Maximum of i_from_ka and i_to_ka [kA]"), - "i_n_ka": pa.Column(float, nullable=True, description=""), # TODO: missing in docu muss mike schauen + "i_n_ka": pa.Column(float, nullable=True, description="Current Neutral [kA]"), "loading_a_percent": pa.Column(float, nullable=True, description="line a loading [%]"), "loading_b_percent": pa.Column(float, nullable=True, description="line b loading [%]"), "loading_c_percent": pa.Column(float, nullable=True, description="line c loading [%]"), - "loading_n_percent": pa.Column(float, nullable=True, description=""), # TODO: was only in docu + "loading_n_percent": pa.Column(float, nullable=True, description="line loading [%]"), }, strict=False, ) diff --git a/pandapower/network_schema/line_dc.py b/pandapower/network_schema/line_dc.py index ec501f940a..a35048bcea 100644 --- a/pandapower/network_schema/line_dc.py +++ b/pandapower/network_schema/line_dc.py @@ -4,7 +4,7 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _line_dc_columns = { - "name": pa.Column(pd.StringDtype, required=False, description="name of the dc line"), + "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the dc line"), "std_type": pa.Column( pd.StringDtype, nullable=True, @@ -26,12 +26,20 @@ "length_km": pa.Column(float, pa.Check.gt(0), description="length of the line [km]"), "r_ohm_per_km": pa.Column(float, pa.Check.ge(0), description="resistance of the line [Ohm per km]"), "g_us_per_km": pa.Column( - float, pa.Check.ge(0), description="dielectric conductance of the dc line [micro Siemens per km]" + float, + pa.Check.ge(0), + description="dielectric conductance of the dc line [micro Siemens per km]", + metadata={"default": 0.0}, ), "max_i_ka": pa.Column(float, pa.Check.ge(0), description="maximal thermal current [kilo Ampere]"), - "parallel": pa.Column(int, pa.Check.ge(1), description="number of parallel dc line systems"), + "parallel": pa.Column( + int, pa.Check.ge(1), description="number of parallel dc line systems", metadata={"default": 1} + ), "df": pa.Column( - float, pa.Check.between(min_value=0, max_value=1), description="derating factor (scaling) for max_i_ka" + float, + pa.Check.between(min_value=0, max_value=1), + description="derating factor (scaling) for max_i_ka", + metadata={"default": 1.0}, ), "type": pa.Column( pd.StringDtype, @@ -39,8 +47,15 @@ required=False, description="type of dc line Naming conventions: “”ol”” - overhead dc line, “”cs”” - underground cable system”", ), - "max_loading_percent": pa.Column(float, pa.Check.gt(0), description="Maximum loading of the dc line"), - "in_service": pa.Column(bool, description="specifies if the dc line is in service."), + "max_loading_percent": pa.Column( + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="Maximum loading of the dc line", + metadata={"opf": True}, + ), + "in_service": pa.Column(bool, description="specifies if the dc line is in service.", metadata={"default": True}), "geo": pa.Column( pd.StringDtype, nullable=True, diff --git a/pandapower/network_schema/load.py b/pandapower/network_schema/load.py index bb104dfc02..2449d3741a 100644 --- a/pandapower/network_schema/load.py +++ b/pandapower/network_schema/load.py @@ -4,12 +4,15 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _load_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the load"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the load", metadata={"cim": True} + ), "bus": pa.Column(int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"}), "p_mw": pa.Column(float, description="active power of the load [MW], a positiv value means power consumption"), "q_mvar": pa.Column( float, description=" reactive power of the load [MVar], positive for inductive consumers, negative for capacitive consumers", + metadata={"default": 0.0}, ), "const_z_p_percent": pa.Column( float, @@ -17,7 +20,7 @@ nullable=True, required=False, description="percentage of p_mw that is associated to constant impedance load at rated voltage [%]", - metadata={"zip": True}, + metadata={"zip": True, "default": 0.0}, ), "const_i_p_percent": pa.Column( float, @@ -25,7 +28,7 @@ nullable=True, required=False, description="percentage of p_mw that is associated to constant current load at rated voltage [%]", - metadata={"zip": True}, + metadata={"zip": True, "default": 0.0}, ), "const_z_q_percent": pa.Column( float, @@ -33,7 +36,7 @@ nullable=True, required=False, description="percentage of q_mvar that is associated to constant impedance load at rated voltage [%]", - metadata={"zip": True}, + metadata={"zip": True, "default": 0.0}, ), "const_i_q_percent": pa.Column( float, @@ -41,24 +44,33 @@ nullable=True, required=False, description="percentage of q_mvar that is associated to constant current load at rated voltage [%]", - metadata={"zip": True}, + metadata={"zip": True, "default": 0.0}, ), "sn_mva": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="rated power of the load [kVA]" + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="rated power of the load [kVA]", + metadata={"cim": True}, ), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for active and reactive power"), - "in_service": pa.Column(bool, description="specifies if the load is in service."), + "scaling": pa.Column( + float, pa.Check.ge(0), description="scaling factor for active and reactive power", metadata={"default": 1.0} + ), + "in_service": pa.Column(bool, description="specifies if the load is in service.", metadata={"default": True}), "type": pa.Column( pd.StringDtype, + pa.Check.isin(["wye", "delta"]), nullable=True, required=False, description="Connection Type of 3 Phase Load(Valid for three phase load flow only) Naming convention: wye, delta", + metadata={"3ph": True, "default": "wye"}, ), "controllable": pa.Column( - pd.BooleanDtype, - nullable=True, + bool, required=False, - description="States if load is controllable or not, load will not be used as a flexibilty if it is not controllable", + description="States if load is controllable or not, load will not be used as a flexibility if it is not controllable", + metadata={"default": False}, ), "zone": pa.Column( pd.StringDtype, @@ -70,6 +82,26 @@ "min_p_mw": pa.Column(float, nullable=True, required=False, description="Minimum active power"), "max_q_mvar": pa.Column(float, nullable=True, required=False, description="Maximum reactive power"), "min_q_mvar": pa.Column(float, nullable=True, required=False, description="Minimum reactive power"), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), } load_schema = pa.DataFrameSchema( _load_columns, diff --git a/pandapower/network_schema/load_dc.py b/pandapower/network_schema/load_dc.py index f735c89df1..e6c395ed8e 100644 --- a/pandapower/network_schema/load_dc.py +++ b/pandapower/network_schema/load_dc.py @@ -8,14 +8,16 @@ int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus_dc.index"} ), "p_dc_mw": pa.Column(float, description="active power of the load [MW] positive value means consumption"), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for active and reactive power"), - "in_service": pa.Column(bool, description="specifies if the load is in service."), + "scaling": pa.Column( + float, pa.Check.ge(0), description="scaling factor for active and reactive power", metadata={"default": 1.0} + ), + "in_service": pa.Column(bool, description="specifies if the load is in service.", metadata={"default": True}), "type": pa.Column(pd.StringDtype, nullable=True, required=False, description="A string describing the type."), "controllable": pa.Column( - pd.BooleanDtype, - nullable=True, + bool, required=False, description="States if load is controllable or not, load will not be used as a flexibilty if it is not controllable", + metadata={"default": False}, ), "zone": pa.Column( pd.StringDtype, diff --git a/pandapower/network_schema/measurement.py b/pandapower/network_schema/measurement.py index ae9beeb8ae..7efccc0008 100644 --- a/pandapower/network_schema/measurement.py +++ b/pandapower/network_schema/measurement.py @@ -33,11 +33,50 @@ "check_existing": pa.Column( bool, description="Checks if a measurement of the type already exists and overwrites it. If set to False, the measurement may be added twice (unsafe behaviour), but the performance increases", + metadata={"default": False}, ), # TODO: shouldn't this be called overwrite? "side": pa.Column( str, description="Only used for measured lines or transformers. Side defines at which end of the branch the measurement is gathered. For lines this may be “from“, “to“ to denote the side with the from_bus or to_bus. It can also be the index of the from_bus or to_bus. For transformers, it can be “hv“, “mv“ or “lv“ or the corresponding bus index, respectively.", ), # TODO: check nur wenn element_type trafo oder line + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "source": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="source from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "analog_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="analog_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), }, strict=False, ) diff --git a/pandapower/network_schema/motor.py b/pandapower/network_schema/motor.py index aa5ed4a9c3..773d19802f 100644 --- a/pandapower/network_schema/motor.py +++ b/pandapower/network_schema/motor.py @@ -4,7 +4,9 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _motor_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the motor"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the motor", metadata={"cim": True} + ), "bus": pa.Column(int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"}), "pn_mech_mw": pa.Column(float, pa.Check.ge(0), description="Mechanical rated power of the motor [MW]"), "cos_phi": pa.Column( @@ -20,6 +22,7 @@ float, pa.Check.between(min_value=0, max_value=100), description="Efficiency in percent at current operating point[%]", + metadata={"default": 100.0}, ), "efficiency_n_percent": pa.Column( float, @@ -31,8 +34,11 @@ float, pa.Check.between(min_value=0, max_value=100), description="The mechanical loading in percentage of the rated mechanical power", + metadata={"default": 100.0}, + ), + "scaling": pa.Column( + float, pa.Check.ge(0), description="scaling factor for active and reactive power", metadata={"default": 1.0} ), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for active and reactive power"), "lrc_pu": pa.Column( float, pa.Check.ge(0), @@ -51,7 +57,27 @@ description="Rated voltage of the motor for short-circuit calculation", metadata={"sc": True}, ), - "in_service": pa.Column(bool, description="specifies if the motor is in service."), + "in_service": pa.Column(bool, description="specifies if the motor is in service.", metadata={"default": True}), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), } motor_schema = pa.DataFrameSchema( _motor_columns, diff --git a/pandapower/network_schema/sgen.py b/pandapower/network_schema/sgen.py index f685c0d0a2..b907ef8da5 100644 --- a/pandapower/network_schema/sgen.py +++ b/pandapower/network_schema/sgen.py @@ -4,35 +4,52 @@ from pandapower.network_schema.tools.validation.group_dependency import create_column_dependency_checks_from_metadata _sgen_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the static generator"), + "name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="name of the static generator", + metadata={"cim": True}, + ), "bus": pa.Column(int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"}), "p_mw": pa.Column(float, description="active power of the static generator [MW]"), - "q_mvar": pa.Column(float, description="reactive power of the static generator [MVAr]"), + "q_mvar": pa.Column(float, description="reactive power of the static generator [MVAr]", metadata={"default": 0.0}), "sn_mva": pa.Column( float, pa.Check.gt(0), nullable=True, required=False, description="rated power ot the static generator [MVA]", + metadata={"cim": True}, + ), + "scaling": pa.Column( + float, pa.Check.ge(0), description="scaling factor for the active and reactive power", metadata={"default": 1.0} ), - "scaling": pa.Column(float, pa.Check.ge(0), description="scaling factor for the active and reactive power"), "min_p_mw": pa.Column( float, nullable=True, required=False, description="maximum active power [MW]", metadata={"opf": True} ), "max_p_mw": pa.Column( - float, nullable=True, required=False, description="minimum active power [MW]", metadata={"opf": True} + float, + nullable=True, + required=False, + description="minimum active power [MW]", + metadata={"opf": True, "cim": True}, ), "min_q_mvar": pa.Column( - float, nullable=True, required=False, description="maximum reactive power [MVAr]", metadata={"opf": True} + float, + nullable=True, + required=False, + description="maximum reactive power [MVAr]", + metadata={"opf": True, "cim": True}, ), "max_q_mvar": pa.Column( float, nullable=True, required=False, description="minimum reactive power [MVAr]", metadata={"opf": True} ), "controllable": pa.Column( - pd.BooleanDtype, - nullable=True, + bool, required=False, description="states if sgen is controllable or not, sgen will not be used as a flexibility if it is not controllable", + metadata={"default": False}, ), "k": pa.Column( float, @@ -40,7 +57,7 @@ nullable=True, required=False, description="ratio of short circuit current to nominal current", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "rx": pa.Column( float, @@ -48,9 +65,9 @@ nullable=True, required=False, description="R/X ratio for short circuit impedance. Only relevant if type is specified as motor so that sgen is treated as asynchronous motor", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), - "in_service": pa.Column(bool, description="specifies if the generator is in service."), + "in_service": pa.Column(bool, description="specifies if the generator is in service.", metadata={"default": True}), "id_q_capability_characteristic": pa.Column( pd.Int64Dtype, nullable=True, @@ -67,6 +84,7 @@ ), "reactive_capability_curve": pa.Column( pd.BooleanDtype, + nullable=True, required=False, description="True if static generator has dependency on q characteristic", metadata={"qcc": True}, @@ -76,27 +94,28 @@ nullable=True, required=False, description="type of generator naming conventions: “PV” - photovoltaic system “WP” - wind power system “CHP” - combined heating and power system", + metadata={"cim": True, "default": "wye"}, ), "current_source": pa.Column( pd.BooleanDtype, nullable=True, required=False, description="Model this sgen as a current source during short- circuit calculations; useful in some cases, for example the simulation of full- size converters per IEC 60909-0:2016.", - metadata={"sc": True}, + metadata={"sc": True, "cim": True, "ucte": True, "default": True}, ), "generator_type": pa.Column( # TODO: is this not an sgen, did someone model motor as an sgen? pd.StringDtype, nullable=True, required=False, description="can be one of current_source (full size converter), async (asynchronous generator), or async_doubly_fed (doubly fed asynchronous generator, DFIG). Represents the type of the static generator in the context of the short-circuit calculations of wind power station units. If None, other short-circuit-related parameters are not set", - metadata={"sc": True}, + metadata={"sc": True, "cim": True, "default": "current_source"}, ), "lrc_pu": pa.Column( float, nullable=True, required=False, description="locked rotor current in relation to the rated generator current. Relevant if the generator_type is async.", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "max_ik_ka": pa.Column( float, @@ -112,6 +131,75 @@ description="the factor for the calculation of the peak short-circuit current, referred to the high-voltage side (provided by the manufacturer). Relevant if the generator_type is async_doubly_fed. If the superposition method is used (use_pre_fault_voltage=True), this parameter is used to pass through the max. current limit of the machine in p.u.", metadata={"sc": True}, ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.mode": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="RegulatingControl.mode from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.targetValue": pa.Column( + float, + nullable=True, + required=False, + description="RegulatingControl.targetValue from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "referencePriority": pa.Column( + float, + nullable=True, + required=False, + description="referencePriority from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vn_kv": pa.Column( + float, + nullable=True, + required=False, + description="vn_kv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "rdss_ohm": pa.Column( + float, + nullable=True, + required=False, + description="rdss_ohm from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "xdss_pu": pa.Column( + float, + nullable=True, + required=False, + description="xdss_pu from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "RegulatingControl.enabled": pa.Column( + pd.BooleanDtype, + nullable=True, + required=False, + description="RegulatingControl.enabled from converter, not relevant for calculations", + metadata={"cim": True}, + ), } sgen_schema = pa.DataFrameSchema( _sgen_columns, diff --git a/pandapower/network_schema/shunt.py b/pandapower/network_schema/shunt.py index 0c5e8ce17f..1d379f30d5 100644 --- a/pandapower/network_schema/shunt.py +++ b/pandapower/network_schema/shunt.py @@ -3,18 +3,28 @@ shunt_schema = pa.DataFrameSchema( { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the shunt"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the shunt", metadata={"cim": True} + ), "bus": pa.Column( int, pa.Check.ge(0), description="index of bus where the impedance starts", metadata={"foreign_key": "bus.index"}, ), - "p_mw": pa.Column(float, pa.Check.ge(0), description="shunt active power in MW at v= 1.0 p.u. per step"), + "p_mw": pa.Column( + float, + pa.Check.ge(0), + description="shunt active power in MW at v= 1.0 p.u. per step", + metadata={"default": 0.0}, + ), "q_mvar": pa.Column(float, description="shunt reactive power in MVAr at v= 1.0 p.u. per step"), "vn_kv": pa.Column(float, pa.Check.gt(0), description="rated voltage of the shunt element"), "step": pa.Column( - float, pa.Check.ge(1), description="step position of the shunt with which power values are multiplied" + float, + pa.Check.ge(1), + description="step position of the shunt with which power values are multiplied", + metadata={"default": 1}, ), "max_step": pa.Column( pd.Int64Dtype, @@ -22,14 +32,15 @@ nullable=True, required=False, description="maximum allowed step of shunt", - metadata={"opf": True}, + metadata={"opf": True, "cim": True, "ucte": True, "default": 1}, ), - "in_service": pa.Column(bool, description="specifies if the shunt is in service"), + "in_service": pa.Column(bool, description="specifies if the shunt is in service", metadata={"default": True}), "step_dependency_table": pa.Column( pd.BooleanDtype, nullable=True, required=False, description="whether the shunt parameters (q_mvar, p_mw) are adjusted dependent on the step of the shunt", + metadata={"cim": True, "default": False}, ), # TODO: remove since it is implied by id_characteristic_table "id_characteristic_table": pa.Column( pd.Int64Dtype, @@ -38,12 +49,41 @@ required=True, # TODO: switch to false, when step_dependancy_table is gone description="references the id_characteristic index from the shunt_characteristic_table", ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "sVCControlMode": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="sVCControlMode from converter, not relevant for calculations", + metadata={"cim": True}, + ), }, checks=[ pa.Check( - lambda df: ( - df["step"] <= df["max_step"] if all(col in df.columns for col in ["step", "max_step"]) else True - ), + lambda df: df["step"] <= df["max_step"] if all(col in df.columns for col in ["step", "max_step"]) else True, error="Column 'step' must be <= column 'max_step'", ) ], diff --git a/pandapower/network_schema/source_dc.py b/pandapower/network_schema/source_dc.py index fcb873435e..d95ac331a0 100644 --- a/pandapower/network_schema/source_dc.py +++ b/pandapower/network_schema/source_dc.py @@ -11,8 +11,11 @@ "vm_pu": pa.Column( float, description="set-point for the bus voltage magnitude at the connection bus", + metadata={"default": 1.0}, + ), + "in_service": pa.Column( + bool, description="specifies if the generator is in service.", metadata={"default": True} ), - "in_service": pa.Column(bool, description="specifies if the generator is in service."), }, strict=False, ) diff --git a/pandapower/network_schema/ssc.py b/pandapower/network_schema/ssc.py index de85394e48..8b37781be0 100644 --- a/pandapower/network_schema/ssc.py +++ b/pandapower/network_schema/ssc.py @@ -14,17 +14,27 @@ float, pa.Check.ge(0), description="resistance of the coupling transformer component of SSC" ), "x_ohm": pa.Column(float, pa.Check.le(0), description="reactance of the coupling transformer component of SSC"), - "set_vm_pu": pa.Column(float, description="set-point for the bus voltage magnitude at the connection bus"), + "set_vm_pu": pa.Column( + float, + description="set-point for the bus voltage magnitude at the connection bus", + metadata={"default": 1.0}, + ), "vm_internal_pu": pa.Column( - float, description="The voltage magnitude of the voltage source converter VSC at the SSC component." + float, + description="The voltage magnitude of the voltage source converter VSC at the SSC component.", + metadata={"default": 1.0}, ), "va_internal_degree": pa.Column( - float, description="The voltage angle of the voltage source converter VSC at the SSC component." + float, + description="The voltage angle of the voltage source converter VSC at the SSC component.", + metadata={"default": 0.0}, ), "controllable": pa.Column( - bool, description="whether the element is considered as actively controlling or as a fixed shunt impedance" + bool, + description="whether the element is considered as actively controlling or as a fixed shunt impedance", + metadata={"default": True}, ), - "in_service": pa.Column(bool, description="specifies if the SSC is in service."), + "in_service": pa.Column(bool, description="specifies if the SSC is in service.", metadata={"default": True}), }, strict=False, ) diff --git a/pandapower/network_schema/storage.py b/pandapower/network_schema/storage.py index 5976ff15e3..52d6afbd5c 100644 --- a/pandapower/network_schema/storage.py +++ b/pandapower/network_schema/storage.py @@ -10,9 +10,11 @@ float, description="Momentary real power of the storage (positive for charging, negative for discharging)", ), - "q_mvar": pa.Column(float, description="Reactive power of the storage [MVar]"), + "q_mvar": pa.Column(float, description="Reactive power of the storage [MVar]", metadata={"default": 0.0}), "sn_mva": pa.Column(float, pa.Check.gt(0), nullable=True, description="Nominal power ot the storage [MVA]"), - "scaling": pa.Column(float, pa.Check.ge(0), description="Scaling factor for the active and reactive power"), + "scaling": pa.Column( + float, description="Scaling factor for the active and reactive power", metadata={"default": 1.0} + ), "max_e_mwh": pa.Column( float, nullable=True, @@ -24,6 +26,7 @@ nullable=True, required=False, description="The minimum energy content of the storage (minimum charge level)", + metadata={"default": 0.0}, ), "max_p_mw": pa.Column( float, nullable=True, required=False, description="Maximum active power", metadata={"opf": True} @@ -45,16 +48,35 @@ float, nullable=True, required=False, description="Minimum reactive power [MVar]", metadata={"opf": True} ), "controllable": pa.Column( - pd.BooleanDtype, - nullable=True, + bool, required=False, description="States if sgen is controllable or not, sgen will not be used as a flexibilty if it is not controllable", - metadata={"opf": True}, + metadata={"opf": True, "default": False}, ), - "in_service": pa.Column(bool, description="Specifies if the generator is in service"), + "in_service": pa.Column(bool, description="Specifies if the generator is in service", metadata={"default": True}), "type": pa.Column( pd.StringDtype, nullable=True, required=False, description="type variable to classify the storage" ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), } storage_schema = pa.DataFrameSchema( _storage_columns, diff --git a/pandapower/network_schema/svc.py b/pandapower/network_schema/svc.py index 6dedcd420a..23a13c365c 100644 --- a/pandapower/network_schema/svc.py +++ b/pandapower/network_schema/svc.py @@ -19,22 +19,24 @@ description="the value of thyristor firing angle of SVC", ), "controllable": pa.Column( - bool, description="whether the element is considered as actively controlling or as a fixed shunt impedance" + bool, + description="whether the element is considered as actively controlling or as a fixed shunt impedance", + metadata={"default": True}, ), - "in_service": pa.Column(bool, description="specifies if the SVC is in service."), + "in_service": pa.Column(bool, description="specifies if the SVC is in service.", metadata={"default": True}), "min_angle_degree": pa.Column( float, pa.Check.ge(90), - nullable=True, required=False, description="minimum value of the thyristor_firing_angle_degree", + metadata={"default": 90}, ), "max_angle_degree": pa.Column( float, pa.Check.le(180), - nullable=True, required=False, description="maximum value of the thyristor_firing_angle_degree", + metadata={"default": 180}, ), }, checks=[ diff --git a/pandapower/network_schema/switch.py b/pandapower/network_schema/switch.py index 54d10563a9..d889915e9e 100644 --- a/pandapower/network_schema/switch.py +++ b/pandapower/network_schema/switch.py @@ -6,7 +6,9 @@ "bus": pa.Column( int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"} ), - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the switch"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the switch", metadata={"cim": True} + ), "element": pa.Column( int, pa.Check.ge(0), @@ -22,8 +24,9 @@ nullable=True, required=False, description="type of switch naming conventions: “CB” - circuit breaker “LS” - load switch “LBS” - load break switch “DS” - disconnecting switch", + metadata={"cim": True}, ), - "closed": pa.Column(bool, description="signals the switching state of the switch"), + "closed": pa.Column(bool, description="signals the switching state of the switch", metadata={"default": True}), "in_ka": pa.Column( float, pa.Check.gt(0), @@ -34,6 +37,38 @@ float, nullable=True, description="indicates the resistance of the switch, which has effect only on bus-bus switches, if sets to 0, the buses will be fused like before, if larger than 0 a branch will be created for the switch which has also effects on the bus mapping", + metadata={"default": 0.0}, + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_bus": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_to from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_element": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_from from converter, not relevant for calculations", + metadata={"cim": True}, ), }, strict=False, diff --git a/pandapower/network_schema/tcsc.py b/pandapower/network_schema/tcsc.py index a25cd150a3..b6bad9ca9d 100644 --- a/pandapower/network_schema/tcsc.py +++ b/pandapower/network_schema/tcsc.py @@ -30,15 +30,18 @@ description="the value of thyristor firing angle of TCSC", ), "controllable": pa.Column( - bool, description="whether the element is considered as actively controlling or as a fixed series impedance" + bool, + description="whether the element is considered as actively controlling or as a fixed series impedance", + metadata={"default": True}, ), - "in_service": pa.Column(bool, description="specifies if the TCSC is in service."), + "in_service": pa.Column(bool, description="specifies if the TCSC is in service.", metadata={"default": True}), "min_angle_degree": pa.Column( float, pa.Check.ge(90), nullable=True, required=False, description="minimum value of the thyristor_firing_angle_degree", + metadata={"default": 90}, ), "max_angle_degree": pa.Column( float, @@ -46,6 +49,7 @@ nullable=True, required=False, description="maximum value of the thyristor_firing_angle_degree", + metadata={"default": 180}, ), }, checks=[ diff --git a/pandapower/network_schema/tools/helper.py b/pandapower/network_schema/tools/helper.py index 76c3c06814..b07a495d95 100644 --- a/pandapower/network_schema/tools/helper.py +++ b/pandapower/network_schema/tools/helper.py @@ -1,13 +1,15 @@ +from importlib.metadata import metadata + import pandera as pa -def get_dtypes(schema: pa.DataFrameSchema, required_only: bool = True) -> dict: +def get_dtypes(schema: pa.DataFrameSchema, required_only: bool = True, metadata: list = []) -> dict: """ Extract column data types from a Pandera DataFrame schema. This function parses a Pandera schema and returns a dictionary mapping column names to their corresponding data types. Optionally filters to - include only required columns. + include only required columns or columns with specific metadata. Args: schema (pa.DataFrameSchema): The Pandera DataFrame schema to extract @@ -15,6 +17,9 @@ def get_dtypes(schema: pa.DataFrameSchema, required_only: bool = True) -> dict: required_only (bool, optional): If True, only includes required columns in the result. If False, includes all columns regardless of their required status. Defaults to True. + metadata (list, optional): List of metadata keys to check. Columns will + be included if they have metadata containing any of these keys with + truthy values. Defaults to empty list. Returns: dict: A dictionary where keys are column names (str) and values are @@ -31,11 +36,18 @@ def get_dtypes(schema: pa.DataFrameSchema, required_only: bool = True) -> dict: {'name': , 'age': } >>> get_dtypes(schema, required_only=False) {'name': , 'age': , 'score': } + + Note: + When metadata parameter is provided, columns with matching metadata keys + having truthy values will be included regardless of required_only setting. """ return { name: col.dtype.type for name, col in schema.columns.items() - if not required_only or schema.columns[name].required + if not required_only + or schema.columns[name].required + or schema.columns[name].metadata is not None + and any(item in schema.columns[name].metadata for item in metadata) } diff --git a/pandapower/network_schema/tools/validation/column_condition.py b/pandapower/network_schema/tools/validation/column_condition.py index aeaa7aaf98..9785bbc143 100644 --- a/pandapower/network_schema/tools/validation/column_condition.py +++ b/pandapower/network_schema/tools/validation/column_condition.py @@ -42,3 +42,44 @@ def create_lower_equals_column_check(first_element: str, second_element: str) -> ), error=f"Column '{first_element}' must be <= column '{second_element}'", ) + + +def create_lower_than_column_check(first_element: str, second_element: str) -> pa.Check: + """ + Create a Pandera check that validates one column is less than another. + + This check verifies that values in the first column are less than + corresponding values in the second column. The check passes (returns True) if: + + - The first column value is < the second column value + - Either column contains NaN values + - Either column is missing from the DataFrame + + Parameters + ---------- + first_element : str + Name of the first column (should contain lower values). + second_element : str + Name of the second column (should contain higher values). + + Returns + ------- + pa.Check + A Pandera Check object that can be applied to a DataFrame schema. + + Examples + -------- + >>> check = create_lower_than_column_check("min_value", "max_value") + >>> schema = pa.DataFrameSchema({ + ... "min_value": pa.Column(float, checks=[check]), + ... "max_value": pa.Column(float) + ... }) + """ + return pa.Check( + lambda df: ( + df[first_element].fillna(-np.inf) < df[second_element].fillna(np.inf) + if all(col in df.columns for col in [first_element, second_element]) + else True + ), + error=f"Column '{first_element}' must be < column '{second_element}'", + ) diff --git a/pandapower/network_schema/tools/validation/group_dependency.py b/pandapower/network_schema/tools/validation/group_dependency.py index 7c5dd63c20..69c93f6907 100644 --- a/pandapower/network_schema/tools/validation/group_dependency.py +++ b/pandapower/network_schema/tools/validation/group_dependency.py @@ -82,7 +82,7 @@ def validator(df): return validator -def create_column_dependency_checks_from_metadata(names: list, schema_columns: dict) -> list: +def create_column_dependency_checks_from_metadata(names: list, schema_columns: dict) -> list[pa.Check]: """ Create dependency validation checks for columns based on their metadata. diff --git a/pandapower/network_schema/trafo.py b/pandapower/network_schema/trafo.py index 8b8068c3fa..5a4b55f360 100644 --- a/pandapower/network_schema/trafo.py +++ b/pandapower/network_schema/trafo.py @@ -5,10 +5,23 @@ create_column_group_dependency_validation_func, create_column_dependency_checks_from_metadata, ) +from pandapower.network_schema.tools.validation.column_condition import create_lower_than_column_check _trafo_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the transformer"), - "std_type": pa.Column(pd.StringDtype, nullable=True, required=False, description="transformer standard type name"), + "name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="name of the transformer", + metadata={"cim": True, "ucte": True}, + ), + "std_type": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="transformer standard type name", + metadata={"cim": True}, + ), "hv_bus": pa.Column( int, pa.Check.ge(0), @@ -33,7 +46,7 @@ nullable=True, required=False, description="zero sequence relative short-circuit voltage", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "vkr0_percent": pa.Column( float, @@ -41,7 +54,7 @@ nullable=True, required=False, description="real part of zero sequence relative short-circuit voltage", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), "mag0_percent": pa.Column( float, @@ -71,39 +84,65 @@ nullable=True, required=False, description="Vector Groups ( required for zero sequence model of transformer )", - metadata={"sc": True, "3ph": True}, + metadata={"sc": True, "3ph": True, "cim": True}, ), - "shift_degree": pa.Column(float, description="transformer phase shift angle"), + "shift_degree": pa.Column( + float, description="transformer phase shift angle", metadata={"default": 0.0} + ), # Thomas: optional "tap_side": pa.Column( pd.StringDtype, pa.Check.isin(["hv", "lv"]), nullable=True, required=False, description="defines if tap changer is at the high- or low voltage side", + metadata={"cim": True, "ucte": True}, + ), # Thomas: null only if no tap_changer_type + "tap_neutral": pa.Column( + float, nullable=True, required=False, description="rated tap position", metadata={"cim": True, "ucte": True} + ), + "tap_min": pa.Column( + float, nullable=True, required=False, description="minimum tap position", metadata={"cim": True, "ucte": True} + ), + "tap_max": pa.Column( + float, nullable=True, required=False, description="maximum tap position", metadata={"cim": True, "ucte": True} ), - "tap_neutral": pa.Column(float, nullable=True, required=False, description="rated tap position"), - "tap_min": pa.Column(float, nullable=True, required=False, description="minimum tap position"), - "tap_max": pa.Column(float, nullable=True, required=False, description="maximum tap position"), "tap_step_percent": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="tap step size for voltage magnitude [%]" + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="tap step size for voltage magnitude [%]", + metadata={"cim": True, "ucte": True}, ), "tap_step_degree": pa.Column( - float, pa.Check.ge(0), nullable=True, required=False, description="tap step size for voltage angle" + float, + pa.Check.ge(0), + nullable=True, + required=False, + description="tap step size for voltage angle", + metadata={"cim": True, "ucte": True}, + ), + "tap_pos": pa.Column( + float, + nullable=True, + required=False, + description="current position of tap changer", + metadata={"cim": True, "ucte": True}, ), - "tap_pos": pa.Column(float, nullable=True, required=False, description="current position of tap changer"), "tap_changer_type": pa.Column( pd.StringDtype, pa.Check.isin(["Ratio", "Symmetrical", "Ideal", "Tabular"]), nullable=True, required=False, description="specifies the tap changer type", + metadata={"cim": True, "ucte": True}, ), "tap_dependency_table": pa.Column( pd.BooleanDtype, nullable=True, required=False, description="whether the transformer parameters (voltage ratio, angle, impedance) are adjusted dependent on the tap position of the transformer", - metadata={"tdt": True}, + metadata={"tdt": True, "cim": True}, ), "id_characteristic_table": pa.Column( pd.Int64Dtype, @@ -111,37 +150,38 @@ nullable=True, required=False, description="references the id_characteristic index from the trafo_characteristic_table", - metadata={"tdt": True}, + metadata={"tdt": True, "cim": True}, ), "max_loading_percent": pa.Column( - int, + float, nullable=True, required=False, description="Maximum loading of the transformer with respect to sn_mva and its corresponding current at 1.0 p.u.", metadata={"opf": True}, ), - "parallel": pa.Column(int, pa.Check.ge(1), description="number of parallel transformers"), + "parallel": pa.Column(int, pa.Check.ge(1), description="number of parallel transformers", metadata={"default": 1}), "df": pa.Column( float, pa.Check.between(min_value=0, max_value=1, include_min=False), nullable=True, required=False, description="derating factor: maximum current of transformer in relation to nominal current of transformer (from 0 to 1)", + metadata={"default": 1.0, "cim": True, "ucte": True}, ), - "in_service": pa.Column(bool, description="specifies if the transformer is in service"), + "in_service": pa.Column(bool, description="specifies if the transformer is in service", metadata={"default": True}), "oltc": pa.Column( - bool, + pd.BooleanDtype, nullable=True, required=False, description="specifies if the transformer has an OLTC (short-circuit relevant)", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "power_station_unit": pa.Column( - bool, + pd.BooleanDtype, nullable=True, required=False, description="specifies if the transformer is part of a power_station_unit (short-circuit relevant) refer to IEC60909-0-2016 section 6.7.1", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "tap2_side": pa.Column( pd.StringDtype, @@ -149,18 +189,39 @@ nullable=True, required=False, description="position of the second tap changer (hv, lv)", + metadata={"cim": True, "ucte": True}, + ), + "tap2_neutral": pa.Column( + float, nullable=True, required=False, description="rated tap position", metadata={"cim": True, "ucte": True} + ), + "tap2_min": pa.Column( + float, nullable=True, required=False, description="minimum tap position", metadata={"cim": True, "ucte": True} + ), + "tap2_max": pa.Column( + float, nullable=True, required=False, description="maximum tap position", metadata={"cim": True, "ucte": True} ), - "tap2_neutral": pa.Column(pd.Float64Dtype, nullable=True, required=False, description="rated tap position"), - "tap2_min": pa.Column(float, nullable=True, required=False, description="minimum tap position"), - "tap2_max": pa.Column(float, nullable=True, required=False, description="maximum tap position"), "tap2_step_percent": pa.Column( - float, pa.Check.gt(0), nullable=True, required=False, description="tap step size for voltage magnitude [%]" + float, + pa.Check.gt(0), + nullable=True, + required=False, + description="tap step size for voltage magnitude [%]", + metadata={"cim": True, "ucte": True}, ), "tap2_step_degree": pa.Column( - float, pa.Check.ge(0), nullable=True, required=False, description="tap step size for voltage angle" + float, + pa.Check.ge(0), + nullable=True, + required=False, + description="tap step size for voltage angle", + metadata={"cim": True, "ucte": True}, ), "tap2_pos": pa.Column( - pd.Float64Dtype, nullable=True, required=False, description="current position of tap changer" + float, + nullable=True, + required=False, + description="current position of tap changer", + metadata={"cim": True, "ucte": True}, ), "tap2_changer_type": pa.Column( pd.StringDtype, @@ -168,6 +229,7 @@ nullable=True, required=False, description="specifies the tap changer type", + metadata={"cim": True, "ucte": True}, ), "leakage_resistance_ratio_hv": pa.Column( float, @@ -189,9 +251,127 @@ float, required=False, description="impedance of the grounding reactor (Z_N) for short circuit calculation", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "pt_percent": pa.Column(float, required=False, description="", metadata={"sc": True}), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "PowerTransformerEnd_id_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="PowerTransformerEnd_id_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "PowerTransformerEnd_id_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="PowerTransformerEnd_id_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger_class from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger2_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger2_class from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger2_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger2_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.limitType_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="OperationalLimitType.limitType_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.limitType_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="OperationalLimitType.limitType_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "CurrentLimit.value_hv": pa.Column( + float, + nullable=True, + required=False, + description="CurrentLimit.value_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "CurrentLimit.value_lv": pa.Column( + float, + nullable=True, + required=False, + description="CurrentLimit.value_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.acceptableDuration_hv": pa.Column( + float, + nullable=True, + required=False, + description="OperationalLimitType.acceptableDuration_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.acceptableDuration_lv": pa.Column( + float, + nullable=True, + required=False, + description="OperationalLimitType.acceptableDuration_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "amica_name": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="amica_name from converter, not relevant for calculations", + metadata={"ucte": True}, + ), } tap2_columns = ["tap2_pos", "tap2_neutral", "tap2_side", "tap2_step_percent", "tap2_step_degree"] tap_columns = [ @@ -220,6 +400,7 @@ ], _trafo_columns, ) +trafo_checks.append(create_lower_than_column_check(first_element="min_angle_degree", second_element="max_angle_degree")) trafo_schema = pa.DataFrameSchema( _trafo_columns, checks=trafo_checks, diff --git a/pandapower/network_schema/trafo3w.py b/pandapower/network_schema/trafo3w.py index 744badedf5..75859cb035 100644 --- a/pandapower/network_schema/trafo3w.py +++ b/pandapower/network_schema/trafo3w.py @@ -5,10 +5,19 @@ create_column_group_dependency_validation_func, create_column_dependency_checks_from_metadata, ) +from pandapower.network_schema.tools.validation.column_condition import create_lower_than_column_check _trafo3w_columns = { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the transformer"), - "std_type": pa.Column(pd.StringDtype, nullable=True, required=False, description="transformer standard type name"), + "name": pa.Column( + pd.StringDtype, nullable=True, required=False, description="name of the transformer", metadata={"cim": True} + ), + "std_type": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="transformer standard type name", + metadata={"cim": True}, + ), "hv_bus": pa.Column( int, pa.Check.ge(0), @@ -66,28 +75,42 @@ "pfe_kw": pa.Column(float, description="iron losses [kW]"), "i0_percent": pa.Column(float, description="open loop losses [%]"), "shift_mv_degree": pa.Column( - float, description="transformer phase shift angle at the MV side" + float, description="transformer phase shift angle at the MV side", metadata={"default": 0.0} ), "shift_lv_degree": pa.Column( - float, description="transformer phase shift angle at the LV side" + float, description="transformer phase shift angle at the LV side", metadata={"default": 0.0} ), "tap_side": pa.Column( pd.StringDtype, - pa.Check.isin(["hv", "mv", "lv"]), nullable=True, required=False, + pa.Check.isin(["hv", "mv", "lv"]), + nullable=True, + required=False, description="defines if tap changer is positioned on high- medium- or low voltage side", + metadata={"cim": True}, + ), + "tap_neutral": pa.Column(float, nullable=True, required=False, description="", metadata={"cim": True}), + "tap_min": pa.Column( + float, nullable=True, required=False, description="minimum tap position", metadata={"cim": True} + ), + "tap_max": pa.Column( + float, nullable=True, required=False, description="maximum tap position", metadata={"cim": True} + ), + "tap_step_percent": pa.Column( + float, pa.Check.gt(0), nullable=True, required=False, description="tap step size [%]", metadata={"cim": True} + ), + "tap_step_degree": pa.Column( + float, nullable=True, required=False, description="tap step size for voltage angle", metadata={"cim": True} ), - "tap_neutral": pa.Column(float, nullable=True, required=False, description=""), - "tap_min": pa.Column(float, nullable=True, required=False, description="minimum tap position"), - "tap_max": pa.Column(float, nullable=True, required=False, description="maximum tap position"), - "tap_step_percent": pa.Column(float, pa.Check.gt(0), nullable=True, required=False, description="tap step size [%]"), - "tap_step_degree": pa.Column(float, nullable=True, required=False, description="tap step size for voltage angle"), "tap_at_star_point": pa.Column( pd.BooleanDtype, nullable=True, required=False, description="whether the tap changer is modelled at terminal or at star point", + metadata={"default": False, "cim": True}, + ), + "tap_pos": pa.Column( + float, nullable=True, required=False, description="current position of tap changer", metadata={"cim": True} ), - "tap_pos": pa.Column(float, nullable=True, required=False, description="current position of tap changer"), "tap_changer_type": pa.Column( pd.StringDtype, pa.Check.isin(["Ratio", "Symmetrical", "Ideal", "Tabular"]), @@ -100,7 +123,7 @@ nullable=True, required=False, description="whether the transformer parameters (voltage ratio, angle, impedance) are adjusted dependent on the tap position of the transformer", - metadata={"tdt": True}, + metadata={"tdt": True, "cim": True}, ), "id_characteristic_table": pa.Column( pd.Int64Dtype, @@ -108,7 +131,7 @@ nullable=True, required=False, description="references the id_characteristic index from the trafo_characteristic_table", - metadata={"tdt": True}, + metadata={"tdt": True, "cim": True}, ), "max_loading_percent": pa.Column( float, @@ -122,7 +145,7 @@ nullable=True, required=False, description="vector group of the 3w-transformer", - metadata={"sc": True}, + metadata={"sc": True, "cim": True}, ), "vkr0_x": pa.Column( float, @@ -136,9 +159,193 @@ required=False, description="", ), - "in_service": pa.Column(bool, description="specifies if the transformer is in service."), + "in_service": pa.Column( + bool, description="specifies if the transformer is in service.", metadata={"default": True} + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, nullable=True, required=False, description="origin_class rdfId from CIM", metadata={"cim": True} + ), + "terminal_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_mv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_mv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "terminal_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "PowerTransformerEnd_id_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="PowerTransformerEnd_id_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "PowerTransformerEnd_id_mv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="PowerTransformerEnd_id_mv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "PowerTransformerEnd_id_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="PowerTransformerEnd_id_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger_class from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "tapchanger_id": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="tapchanger_id from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.limitType_hv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="OperationalLimitType.limitType_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.limitType_mv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="OperationalLimitType.limitType_mv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.limitType_lv": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="OperationalLimitType.limitType_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vk0_hv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vk0_hv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vk0_mv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vk0_mv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vk0_lv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vk0_lv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vkr0_hv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vkr0_hv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vkr0_mv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vkr0_mv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "vkr0_lv_percent": pa.Column( # TODO: check if really only cim + float, + nullable=True, + required=False, + description="vkr0_lv_percent from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "CurrentLimit.value_hv": pa.Column( + float, + nullable=True, + required=False, + description="CurrentLimit.value_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "CurrentLimit.value_mv": pa.Column( + float, + nullable=True, + required=False, + description="CurrentLimit.value_mv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "CurrentLimit.value_lv": pa.Column( + float, + nullable=True, + required=False, + description="CurrentLimit.value_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.acceptableDuration_hv": pa.Column( + float, + nullable=True, + required=False, + description="OperationalLimitType.acceptableDuration_hv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.acceptableDuration_mv": pa.Column( + float, + nullable=True, + required=False, + description="OperationalLimitType.acceptableDuration_mv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "OperationalLimitType.acceptableDuration_lv": pa.Column( + float, + nullable=True, + required=False, + description="OperationalLimitType.acceptableDuration_lv from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "power_station_unit": pa.Column( # TODO: check if really only cim + pd.BooleanDtype, + nullable=True, + required=False, + description="power_station_unit from converter, not relevant for calculations", + metadata={"cim": True}, + ), } -tap_columns = ["tap_pos", "tap_neutral", "tap_side", "tap_step_percent", "tap_step_degree"] +# FIXME: either tap_step_percent or tap_step_degree for each row +tap_columns = ["tap_pos", "tap_neutral", "tap_side"] # , "tap_step_percent", "tap_step_degree"] trafo3w_checks = [ pa.Check( create_column_group_dependency_validation_func(tap_columns), @@ -153,6 +360,9 @@ ], _trafo3w_columns, ) +trafo3w_checks.append(create_lower_than_column_check(first_element="vkr_hv_percent", second_element="vk_hv_percent")) +trafo3w_checks.append(create_lower_than_column_check(first_element="vkr_mv_percent", second_element="vk_mv_percent")) +trafo3w_checks.append(create_lower_than_column_check(first_element="vkr_lv_percent", second_element="vk_lv_percent")) trafo3w_schema = pa.DataFrameSchema( _trafo3w_columns, checks=trafo3w_checks, diff --git a/pandapower/network_schema/vsc.py b/pandapower/network_schema/vsc.py index de6f2afab8..138fce4d61 100644 --- a/pandapower/network_schema/vsc.py +++ b/pandapower/network_schema/vsc.py @@ -22,11 +22,17 @@ "pl_dc_mw": pa.Column( float, description="no-load losses of the VSC on the DC side for the shunt R representing the no load losses", + metadata={"default": 0.0}, ), "control_mode_ac": pa.Column( - str, pa.Check.isin(["vm_pu", "q_mvar", "slack"]), description="the control mode of the AC side of the VSC" + str, + pa.Check.isin(["vm_pu", "q_mvar", "slack"]), + description="the control mode of the AC side of the VSC", + metadata={"default": "vm_pu"}, + ), + "control_value_ac": pa.Column( + float, description="the value of the controlled parameter at the ac bus", metadata={"default": 1.0} ), - "control_value_ac": pa.Column(float, description="the value of the controlled parameter at the ac bus"), "control_mode_dc": pa.Column( str, pa.Check.isin( @@ -36,11 +42,16 @@ ] ), description="the control mode of the dc side of the VSC", + metadata={"default": "p_mw"}, + ), + "control_value_dc": pa.Column( + float, description="the value of the controlled parameter at the dc bus", metadata={"default": 0.0} + ), + "controllable": pa.Column( + bool, description="whether the element is considered as actively controlling", metadata={"default": True} ), - "control_value_dc": pa.Column(float, description="the value of the controlled parameter at the dc bus"), - "controllable": pa.Column(bool, description="whether the element is considered as actively controlling"), - "in_service": pa.Column(bool, description="specifies if the VSC is in service."), - # "ref_bus": pa.Column(int, pa.Check.ge(0), description=""), #TODO: Mike + "in_service": pa.Column(bool, description="specifies if the VSC is in service.", metadata={"default": True}), + # "ref_bus": pa.Column(int, pa.Check.ge(0), description=""), #TODO: implementation currently not finished }, strict=False, ) diff --git a/pandapower/network_schema/vsc_bipolar.py b/pandapower/network_schema/vsc_bipolar.py index 7391704f42..47462e4a03 100644 --- a/pandapower/network_schema/vsc_bipolar.py +++ b/pandapower/network_schema/vsc_bipolar.py @@ -15,21 +15,32 @@ "pl_dc_mw": pa.Column( float, description="no-load losses of the VSC on the DC side for the shunt R representing the no load losses", + metadata={"default": 0.0}, ), "control_mode": pa.Column( - str, description="the control mode of the ac side of the VSC. Can be 'Vac_phi', 'Vdc_phi', 'Vdc_Q', 'Pac_Vac', 'Pac_Qac' or 'Vdc_Vac'" + str, + pa.Check.isin(["Vac_phi", "Vdc_phi", "Vdc_Q", "Pac_Vac", "Pac_Qac", "Vdc_Vac"]), + description="the control mode of the ac side of the VSC. Must be 'Vac_phi', 'Vdc_phi', 'Vdc_Q', 'Pac_Vac', 'Pac_Qac' or 'Vdc_Vac'", + metadata={"default": "Vac_phi"}, ), "control_value_1": pa.Column( - float, description="The first controlled parameter, for example voltage magnitude or phase" + float, + description="The first controlled parameter, for example voltage magnitude or phase", + metadata={"default": 1.0}, ), "control_value_2": pa.Column( - float, description="The second controlled parameter, also depends on the control mode" + float, + description="The second controlled parameter, also depends on the control mode", + metadata={"default": 0.0}, ), "controllable": pa.Column( bool, description="whether the element is considered as actively controlling or as a fixed voltage source connected via shunt impedance", + metadata={"default": True}, + ), + "in_service": pa.Column( + bool, description="True for in_service or False for out of service", metadata={"default": True} ), - "in_service": pa.Column(bool, description="True for in_service or False for out of service"), }, strict=False, ) diff --git a/pandapower/network_schema/vsc_schema.py b/pandapower/network_schema/vsc_stacked.py similarity index 55% rename from pandapower/network_schema/vsc_schema.py rename to pandapower/network_schema/vsc_stacked.py index 80f7840566..f17144a7f4 100644 --- a/pandapower/network_schema/vsc_schema.py +++ b/pandapower/network_schema/vsc_stacked.py @@ -3,23 +3,23 @@ vsc_stacked_schema = pa.DataFrameSchema( { - "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the B2B VSC"), + "name": pa.Column(pd.StringDtype, nullable=True, required=False, description="name of the vsc_stacked"), "bus": pa.Column( int, pa.Check.ge(0), - description="index of ac bus of the ac side of the B2B VSC", + description="index of ac bus of the ac side of the vsc_stacked", metadata={"foreign_key": "bus.index"}, ), "bus_dc_plus": pa.Column( int, pa.Check.ge(0), - description="index of dc bus of the plus dc side of the B2B VSC", + description="index of dc bus of the plus dc side of the vsc_stacked", metadata={"foreign_key": "bus_dc.index"}, ), "bus_dc_minus": pa.Column( int, pa.Check.ge(0), - description="index of dc bus of the minus dc side of the B2B VSC", + description="index of dc bus of the minus dc side of the vsc_stacked", metadata={"foreign_key": "bus_dc.index"}, ), "r_ohm": pa.Column(float, description="resistance of the coupling transformer"), @@ -28,40 +28,59 @@ "pl_dc_mw": pa.Column( float, description="no-load losses of the VSC on the DC side for the shunt R representing the no load losses", + metadata={"default": 0.0}, ), "control_mode_ac": pa.Column( str, pa.Check.isin(["vm_pu", "q_mvar", "slack"]), - description="the control mode of the AC side of the B2B VSC", + description="the control mode of the AC side of the vsc_stacked", + metadata={"default": "p_mw"}, + ), + "control_value_ac": pa.Column( + float, description="the value of the controlled parameter at the ac bus", metadata={"default": 1.0} ), - "control_value_ac": pa.Column(float, description="the value of the controlled parameter at the ac bus"), "control_mode_dc": pa.Column( - str, pa.Check.isin(["vm_pu", "p_mw"]), description="the control mode of the dc side of the B2B VSC" + str, + pa.Check.isin(["vm_pu", "p_mw"]), + description="the control mode of the dc side of the vsc_stacked", + metadata={"default": "p_mw"}, + ), + "control_value_dc": pa.Column( + float, description="the value of the controlled parameter at the dc bus", metadata={"default": 0.0} + ), + "controllable": pa.Column( + bool, description="whether the element is considered as actively controlling", metadata={"default": True} + ), + "in_service": pa.Column( + bool, description="specifies if the vsc_stacked is in service.", metadata={"default": True} ), - "control_value_dc": pa.Column(float, description="the value of the controlled parameter at the dc bus"), - "controllable": pa.Column(bool, description="whether the element is considered as actively controlling"), - "in_service": pa.Column(bool, description="specifies if the B2B VSC is in service."), }, strict=False, ) res_vsc_stacked_schema = pa.DataFrameSchema( { - "p_mw": pa.Column(float, nullable=True, description="total active power consumption of B2B VSC [MW]"), - "q_mvar": pa.Column(float, nullable=True, description="total reactive power consumption of B2B VSC [MVAr]"), + "p_mw": pa.Column(float, nullable=True, description="total active power consumption of vsc_stacked [MW]"), + "q_mvar": pa.Column(float, nullable=True, description="total reactive power consumption of vsc_stacked [MVAr]"), "p_dc_mw_p": pa.Column(float, nullable=True, description="voltage magnitude at vsc internal bus [pu]"), "p_dc_mw_m": pa.Column(float, nullable=True, description="voltage angle at vsc internal bus [degree]"), - "vm_internal_pu": pa.Column(float, nullable=True, description="voltage magnitude at B2B VSC ac bus [pu]"), - "vm_internal_degree": pa.Column(float, nullable=True, description="voltage angle at B2B VSC ac bus [degree]"), - "vm_pu": pa.Column(float, nullable=True, description="active power of the plus side of the B2B VSC [MW]"), - "va_degree": pa.Column(float, nullable=True, description="active power of the minus side of B2B VSC [MW]"), + "vm_internal_pu": pa.Column(float, nullable=True, description="voltage magnitude at vsc_stacked ac bus [pu]"), + "vm_internal_degree": pa.Column( + float, nullable=True, description="voltage angle at vsc_stacked ac bus [degree]" + ), + "vm_pu": pa.Column(float, nullable=True, description="active power of the plus side of the vsc_stacked [MW]"), + "va_degree": pa.Column(float, nullable=True, description="active power of the minus side of vsc_stacked [MW]"), "vm_internal_dc_pu_p": pa.Column( - float, nullable=True, description="voltage angle at the plus B2B VSC ac bus [pu]" + float, nullable=True, description="voltage angle at the plus vsc_stacked ac bus [pu]" ), "vm_internal_dc_pu_m": pa.Column( - float, nullable=True, description="voltage angle at the minus B2B VSC ac bus [pu]" + float, nullable=True, description="voltage angle at the minus vsc_stacked ac bus [pu]" + ), + "vm_dc_pu_p": pa.Column( + float, nullable=True, description="voltage magnitude at the plus vsc_stacked ac bus [pu]" + ), + "vm_dc_pu_m": pa.Column( + float, nullable=True, description="voltage magnitude at the minus vsc_stacked ac bus [pu]" ), - "vm_dc_pu_p": pa.Column(float, nullable=True, description="voltage magnitude at the plus B2B VSC ac bus [pu]"), - "vm_dc_pu_m": pa.Column(float, nullable=True, description="voltage magnitude at the minus B2B VSC ac bus [pu]"), }, ) diff --git a/pandapower/network_schema/ward.py b/pandapower/network_schema/ward.py index 38cfe84976..3b516deda4 100644 --- a/pandapower/network_schema/ward.py +++ b/pandapower/network_schema/ward.py @@ -8,6 +8,7 @@ nullable=True, required=False, description="name of the ward equivalent", + metadata={"cim": True}, ), "bus": pa.Column( int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"} @@ -16,7 +17,33 @@ "qs_mvar": pa.Column(float, description="constant reactive power demand [MVar]"), "pz_mw": pa.Column(float, description="constant impedance active power demand at 1.0 pu [MW]"), "qz_mvar": pa.Column(float, description="constant impedance reactive power demand at 1.0 pu [MVar]"), - "in_service": pa.Column(bool, description="specifies if the ward equivalent is in service."), + "in_service": pa.Column( + bool, description="specifies if the ward equivalent is in service.", metadata={"default": True} + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, + ), }, strict=False, ) diff --git a/pandapower/network_schema/xward.py b/pandapower/network_schema/xward.py index b92b5ea884..d0f6de0fb6 100644 --- a/pandapower/network_schema/xward.py +++ b/pandapower/network_schema/xward.py @@ -4,7 +4,11 @@ xward_schema = pa.DataFrameSchema( { "name": pa.Column( - pd.StringDtype, nullable=True, required=False, description="name of the extended ward equivalent" + pd.StringDtype, + nullable=True, + required=False, + description="name of the extended ward equivalent", + metadata={"cim": True}, ), "bus": pa.Column( int, pa.Check.ge(0), description="index of connected bus", metadata={"foreign_key": "bus.index"} @@ -17,9 +21,39 @@ "x_ohm": pa.Column(float, pa.Check.gt(0), description="internal reactance of the voltage source [ohm]"), "vm_pu": pa.Column(float, pa.Check.gt(0), description="voltage source set point [p.u]"), "slack_weight": pa.Column( - float, nullable=True, required=False, description=" Contribution factor for distributed slack power" + float, + nullable=True, + required=False, + description=" Contribution factor for distributed slack power", + metadata={"cim": True, "default": 0.0}, + ), + "in_service": pa.Column( + bool, description="specifies if the extended ward equivalent is in service.", metadata={"default": True} + ), + "origin_id": pa.Column( + pd.StringDtype, nullable=True, required=False, description="element rdfId from CIM", metadata={"cim": True} + ), + "origin_class": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="origin_class rdfId from CIM", + metadata={"cim": True}, + ), + "terminal": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="terminal from converter, not relevant for calculations", + metadata={"cim": True}, + ), + "description": pa.Column( + pd.StringDtype, + nullable=True, + required=False, + description="description from converter, not relevant for calculations", + metadata={"cim": True}, ), - "in_service": pa.Column(bool, description="specifies if the extended ward equivalent is in service."), }, strict=False, ) diff --git a/pandapower/network_structure.py b/pandapower/network_structure.py index bcfaa25085..0208620e64 100644 --- a/pandapower/network_structure.py +++ b/pandapower/network_structure.py @@ -1,386 +1,116 @@ +from typing import Any + from numpy import dtype import pandas as pd +from pandera import DataFrameSchema + from pandapower._version import __version__, __format_version__ +from pandapower.network_schema.tools.helper import get_dtypes +from pandapower.network_schema import * # noqa: F403 + + +def get_table_schema() -> dict[str, DataFrameSchema]: + # ruff: noqa: F405 + return { + "bus": bus_schema, + "bus_dc": bus_dc_schema, + "load": load_schema, + "sgen": sgen_schema, + "motor": motor_schema, + "asymmetric_load": asymmetric_load_schema, + "asymmetric_sgen": asymmetric_sgen_schema, + "storage": storage_schema, + "gen": gen_schema, + "switch": switch_schema, + "shunt": shunt_schema, + "svc": svc_schema, + "ssc": ssc_schema, + "vsc": vsc_schema, + "ext_grid": ext_grid_schema, + "line": line_schema, + "line_dc": line_dc_schema, + "trafo": trafo_schema, + "trafo3w": trafo3w_schema, + "impedance": impedance_schema, + "tcsc": tcsc_schema, + "dcline": dcline_schema, + "ward": ward_schema, + "xward": xward_schema, + "measurement": measurement_schema, + "source_dc": source_dc_schema, + "load_dc": load_dc_schema, + "vsc_stacked": vsc_stacked_schema, + "vsc_bipolar": vsc_bipolar_schema, + # result tables + "_empty_res_bus": res_bus_schema, + "_empty_res_bus_dc": res_bus_dc_schema, + "_empty_res_ext_grid": res_ext_grid_schema, + "_empty_res_line": res_line_schema, + "_empty_res_line_dc": res_line_dc_schema, + "_empty_res_trafo": res_trafo_schema, + "_empty_res_load": res_load_schema, + "_empty_res_load_3ph": res_load_schema, + "_empty_res_asymmetric_load": res_asymmetric_load_schema, + "_empty_res_asymmetric_sgen": res_asymmetric_sgen_schema, + "_empty_res_motor": res_motor_schema, + "_empty_res_sgen": res_sgen_schema, + "_empty_res_sgen_3ph": res_sgen_schema, + "_empty_res_shunt": res_shunt_schema, + "_empty_res_svc": res_svc_schema, + "_empty_res_ssc": res_ssc_schema, + "_empty_res_vsc": res_vsc_schema, + "_empty_res_switch": res_switch_schema, + "_empty_res_impedance": res_impedance_schema, + "_empty_res_tcsc": res_tcsc_schema, + "_empty_res_dcline": res_dcline_schema, + "_empty_res_source_dc": res_source_dc_schema, + "_empty_res_load_dc": res_load_dc_schema, + "_empty_res_ward": res_ward_schema, + "_empty_res_xward": res_xward_schema, + "_empty_res_trafo_3ph": res_trafo_3ph_schema, + "_empty_res_trafo3w": res_trafo3w_schema, + "_empty_res_bus_3ph": res_bus_3ph_schema, + "_empty_res_ext_grid_3ph": res_ext_grid_3ph_schema, + "_empty_res_line_3ph": res_line_3ph_schema, + "_empty_res_asymmetric_load_3ph": res_asymmetric_load_3ph_schema, + "_empty_res_asymmetric_sgen_3ph": res_asymmetric_sgen_3ph_schema, + "_empty_res_storage": res_storage_schema, + "_empty_res_storage_3ph": res_storage_3ph_schema, + "_empty_res_gen": res_gen_schema, + "_empty_res_vsc_stacked": res_vsc_stacked_schema, + "_empty_res_vsc_bipolar": res_vsc_bipolar_schema + } + # ruff: enable + + +def get_column_info(table: str, column: str) -> dict[str, str | bool | dict] | None: + schema = get_table_schema().get(table, None) + if schema is None: + return schema + column = schema.columns.get(column, None) + if column is None: + return column + return column.__dict__ +def get_default_value(table: str, column: str) -> Any: + column_info: dict[str, Any] | None = get_column_info(table, column) + if column_info is not None and 'metadata' in column_info and 'default' in column_info['metadata']: + return column_info["metadata"]["default"] + return pd.NA -def get_structure_dict() -> dict: +def get_structure_dict(required_only: bool = True, metadata: list = []) -> dict: """ This function returns the structure dict of the network """ - return { - # structure data - "bus": { - "name": dtype(object), - "vn_kv": "f8", - "type": dtype(object), - "zone": dtype(object), - "in_service": "bool", - "geo": dtype(str), - }, - "bus_dc": { - "name": dtype(object), - "vn_kv": "f8", - "type": dtype(object), - "zone": dtype(object), - "in_service": "bool", - "geo": dtype(str), - }, - "load": { - "name": dtype(object), - "bus": "u4", - "p_mw": "f8", - "q_mvar": "f8", - "const_z_p_percent": "f8", - "const_i_p_percent": "f8", - "const_z_q_percent": "f8", - "const_i_q_percent": "f8", - "sn_mva": "f8", - "scaling": "f8", - "in_service": "bool", - "type": dtype(object), - }, - "sgen": { - "name": dtype(object), - "bus": "i8", - "p_mw": "f8", - "q_mvar": "f8", - "min_q_mvar": "f8", - "max_q_mvar": "f8", - "sn_mva": "f8", - "scaling": "f8", - "controllable": "bool", - "id_q_capability_characteristic": pd.Int64Dtype(), - "reactive_capability_curve": "bool", - "curve_style": dtype(object), - "in_service": "bool", - "type": dtype(object), - "current_source": "bool", - }, - "motor": { - "name": dtype(object), - "bus": "i8", - "pn_mech_mw": "f8", - "loading_percent": "f8", - "cos_phi": "f8", - "cos_phi_n": "f8", - "efficiency_percent": "f8", - "efficiency_n_percent": "f8", - "lrc_pu": "f8", - "vn_kv": "f8", - "scaling": "f8", - "in_service": "bool", - "rx": "f8", - }, - "asymmetric_load": { - "name": dtype(object), - "bus": "u4", - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - "sn_a_mva": "f8", - "sn_b_mva": "f8", - "sn_c_mva": "f8", - "sn_mva": "f8", - "scaling": "f8", - "in_service": "bool", - "type": dtype(object), - }, - "asymmetric_sgen": { - "name": dtype(object), - "bus": "i8", - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - "sn_a_mva": "f8", - "sn_b_mva": "f8", - "sn_c_mva": "f8", - "sn_mva": "f8", - "scaling": "f8", - "in_service": "bool", - "type": dtype(object), - "current_source": "bool", - }, - "storage": { - "name": dtype(object), - "bus": "i8", - "p_mw": "f8", - "q_mvar": "f8", - "sn_mva": "f8", - "soc_percent": "f8", - "min_e_mwh": "f8", - "max_e_mwh": "f8", - "scaling": "f8", - "in_service": "bool", - "type": dtype(object), - }, - "gen": { - "name": dtype(object), - "bus": "u4", - "p_mw": "f8", - "vm_pu": "f8", - "sn_mva": "f8", - "min_q_mvar": "f8", - "max_q_mvar": "f8", - "scaling": "f8", - "slack": "bool", - "controllable": "bool", - "id_q_capability_characteristic": pd.Int64Dtype(), - "reactive_capability_curve": "bool", - "curve_style": dtype(object), - "in_service": "bool", - "slack_weight": "f8", - "type": dtype(object), - }, - "switch": { - "bus": "i8", - "element": "i8", - "et": dtype(object), - "type": dtype(object), - "closed": "bool", - "name": dtype(object), - "z_ohm": "f8", - "in_ka": "f8", - }, - "shunt": { - "bus": "u4", - "name": dtype(object), - "q_mvar": "f8", - "p_mw": "f8", - "vn_kv": "f8", - "step": "f8", - "max_step": "u4", - "id_characteristic_table": pd.Int64Dtype(), - "step_dependency_table": "bool", - "in_service": "bool", - }, - "svc": { - "name": dtype(object), - "bus": "u4", - "x_l_ohm": "f8", - "x_cvar_ohm": "f8", - "set_vm_pu": "f8", - "thyristor_firing_angle_degree": "f8", - "controllable": "bool", - "in_service": "bool", - "min_angle_degree": "f8", - "max_angle_degree": "f8", - }, - "ssc": { - "name": dtype(object), - "bus": "u4", - "r_ohm": "f8", - "x_ohm": "f8", - "vm_internal_pu": "f8", - "va_internal_degree": "f8", - "set_vm_pu": "f8", - "controllable": "bool", - "in_service": "bool", - }, - "vsc": { - "name": dtype(object), - "bus": "u4", - "bus_dc": "u4", - "r_ohm": "f8", - "x_ohm": "f8", - "r_dc_ohm": "f8", - "pl_dc_mw": "f8", - "control_mode_ac": dtype(object), - "control_value_ac": "f8", - "control_mode_dc": dtype(object), - "control_value_dc": "f8", - "controllable": "bool", - "in_service": "bool", - "ref_bus": "u4", - }, - "ext_grid": { - "name": dtype(object), - "bus": "u4", - "vm_pu": "f8", - "va_degree": "f8", - "slack_weight": "f8", - "in_service": "bool", - "controllable": "bool", - }, - "line": { - "name": dtype(object), - "std_type": dtype(object), - "from_bus": "u4", - "to_bus": "u4", - "length_km": "f8", - "r_ohm_per_km": "f8", - "x_ohm_per_km": "f8", - "c_nf_per_km": "f8", - "g_us_per_km": "f8", - "max_i_ka": "f8", - "df": "f8", - "parallel": "u4", - "type": dtype(object), - "in_service": "bool", - "geo": dtype(str), - }, - "line_dc": { - "name": dtype(object), - "std_type": dtype(object), - "from_bus_dc": "u4", - "to_bus_dc": "u4", - "length_km": "f8", - "r_ohm_per_km": "f8", - "g_us_per_km": "f8", - "max_i_ka": "f8", - "df": "f8", - "parallel": "u4", - "type": dtype(object), - "in_service": "bool", - "geo": dtype(str), - }, - "trafo": { - "name": dtype(object), - "std_type": dtype(object), - "hv_bus": "u4", - "lv_bus": "u4", - "sn_mva": "f8", - "vn_hv_kv": "f8", - "vn_lv_kv": "f8", - "vk_percent": "f8", - "vkr_percent": "f8", - "pfe_kw": "f8", - "i0_percent": "f8", - "shift_degree": "f8", - "tap_side": dtype(object), - "tap_neutral": "f8", - "tap_min": "f8", - "tap_max": "f8", - "tap_step_percent": "f8", - "tap_step_degree": "f8", - "tap_pos": "f8", - "tap_changer_type": dtype(object), - "id_characteristic_table": pd.Int64Dtype(), - "tap_dependency_table": "bool", - "parallel": "u4", - "df": "f8", - "in_service": "bool", - }, - "trafo3w": { - "name": dtype(object), - "std_type": dtype(object), - "hv_bus": "u4", - "mv_bus": "u4", - "lv_bus": "u4", - "sn_hv_mva": "f8", - "sn_mv_mva": "f8", - "sn_lv_mva": "f8", - "vn_hv_kv": "f8", - "vn_mv_kv": "f8", - "vn_lv_kv": "f8", - "vk_hv_percent": "f8", - "vk_mv_percent": "f8", - "vk_lv_percent": "f8", - "vkr_hv_percent": "f8", - "vkr_mv_percent": "f8", - "vkr_lv_percent": "f8", - "pfe_kw": "f8", - "i0_percent": "f8", - "shift_mv_degree": "f8", - "shift_lv_degree": "f8", - "tap_side": dtype(object), - "tap_neutral": "f8", - "tap_min": "f8", - "tap_max": "f8", - "tap_step_percent": "f8", - "tap_step_degree": "f8", - "tap_pos": "f8", - "tap_at_star_point": "bool", - "tap_changer_type": dtype(object), - "id_characteristic_table": pd.Int64Dtype(), - "tap_dependency_table": "bool", - "in_service": "bool", - }, - "impedance": { - "name": dtype(object), - "from_bus": "u4", - "to_bus": "u4", - "rft_pu": "f8", - "xft_pu": "f8", - "rtf_pu": "f8", - "xtf_pu": "f8", - "gf_pu": "f8", - "bf_pu": "f8", - "gt_pu": "f8", - "bt_pu": "f8", - "sn_mva": "f8", - "in_service": "bool", - }, - "tcsc": { - "name": dtype(object), - "from_bus": "u4", - "to_bus": "u4", - "x_l_ohm": "f8", - "x_cvar_ohm": "f8", - "set_p_to_mw": "f8", - "thyristor_firing_angle_degree": "f8", - "controllable": "bool", - "in_service": "bool", - }, - "dcline": { - "name": dtype(object), - "from_bus": "u4", - "to_bus": "u4", - "p_mw": "f8", - "loss_percent": "f8", - "loss_mw": "f8", - "vm_from_pu": "f8", - "vm_to_pu": "f8", - "max_p_mw": "f8", - "min_q_from_mvar": "f8", - "min_q_to_mvar": "f8", - "max_q_from_mvar": "f8", - "max_q_to_mvar": "f8", - "in_service": "bool", - }, - "ward": { - "name": dtype(object), - "bus": "u4", - "ps_mw": "f8", - "qs_mvar": "f8", - "qz_mvar": "f8", - "pz_mw": "f8", - "in_service": "bool", - }, - "xward": { - "name": dtype(object), - "bus": "u4", - "ps_mw": "f8", - "qs_mvar": "f8", - "qz_mvar": "f8", - "pz_mw": "f8", - "r_ohm": "f8", - "x_ohm": "f8", - "vm_pu": "f8", - "slack_weight": "f8", - "in_service": "bool", - }, - "measurement": { - "name": dtype(object), - "measurement_type": dtype(object), - "element_type": dtype(object), - "element": "uint32", - "value": "float64", - "std_dev": "float64", - "side": dtype(object), - }, - "pwl_cost": { + dtypes_dict: dict[str, Any] = {key: get_dtypes(val, required_only, metadata) for key, val in get_table_schema().items()} + dtypes_dict.update({ + "pwl_cost": { # TODO: convert to pandera "power_type": dtype(object), "element": "u4", "et": dtype(object), "points": dtype(object), }, - "poly_cost": { + "poly_cost": { # TODO: convert to pandera "element": "u4", "et": dtype(object), "cp0_eur": "f8", @@ -390,7 +120,7 @@ def get_structure_dict() -> dict: "cq1_eur_per_mvar": "f8", "cq2_eur_per_mvar2": "f8", }, - "controller": { + "controller": { # TODO: convert to pandera "object": dtype(object), "in_service": "bool", "order": "float64", @@ -398,396 +128,13 @@ def get_structure_dict() -> dict: "initial_run": "bool", "recycle": dtype(object), }, - "group": { + "group": { # TODO: convert to pandera "name": dtype(object), "element_type": dtype(object), "element_index": dtype(object), "reference_column": dtype(object), }, - "source_dc": { - "name": dtype(object), - "bus_dc": "u4", - "vm_pu": "f8", - "in_service": "bool", - "type": dtype(object), - }, - "load_dc": { - "name": dtype(object), - "bus_dc": "u4", - "p_dc_mw": "f8", - "scaling": "f8", - "in_service": "bool", - "controllable": "bool", - "type": dtype(object), - }, - "vsc_stacked": { - "name": dtype(object), - "bus": "u4", - "bus_dc_plus": "u4", - "bus_dc_minus": "u4", - "r_ohm": "f8", - "x_ohm": "f8", - "r_dc_ohm": "f8", - "pl_dc_mw": "f8", - "control_mode_ac": dtype(object), - "control_value_ac": "f8", - "control_mode_dc": dtype(object), - "control_value_dc": "f8", - "controllable": "bool", - "in_service": "bool", - }, - "vsc_bipolar": { - "name": dtype(object), - "bus": "u4", - "bus_dc_plus": "u4", - "bus_dc_minus": "u4", - "r_ohm": "f8", - "x_ohm": "f8", - "r_dc_ohm": "f8", - "pl_dc_mw": "f8", - "control_mode": dtype(object), - "control_value_1": "f8", - "control_value_2": "f8", - "controllable": "bool", - "in_service": "bool", - }, # result tables - "_empty_res_vsc_stacked": { - "p_mw": "f8", - "q_mvar": "f8", - "p_dc_mw_p": "f8", - "p_dc_mw_m": "f8", - "vm_internal_pu": "f8", - "vm_internal_degree": "f8", - "vm_pu": "f8", - "va_degree": "f8", - "vm_internal_dc_pu_p": "f8", - "vm_internal_dc_pu_m": "f8", - "vm_dc_pu_p": "f8", - "vm_dc_pu_m": "f8", - }, - "_empty_res_vsc_bipolar": { - "p_mw": "f8", - "q_mvar": "f8", - "p_dc_mw_p": "f8", - "p_dc_mw_m": "f8", - "vm_internal_pu": "f8", - "vm_internal_degree": "f8", - "vm_pu": "f8", - "va_degree": "f8", - "vm_internal_dc_pu_p": "f8", - "vm_internal_dc_pu_m": "f8", - "vm_dc_pu_p": "f8", - "vm_dc_pu_m": "f8", - }, - "_empty_res_bus": { - "vm_pu": "f8", - "va_degree": "f8", - "p_mw": "f8", - "q_mvar": "f8"}, - "_empty_res_bus_dc": { - "vm_pu": "f8", - "p_mw": "f8"}, - "_empty_res_ext_grid": { - "p_mw": "f8", - "q_mvar": "f8"}, - "_empty_res_line": { - "p_from_mw": "f8", - "q_from_mvar": "f8", - "p_to_mw": "f8", - "q_to_mvar": "f8", - "pl_mw": "f8", - "ql_mvar": "f8", - "i_from_ka": "f8", - "i_to_ka": "f8", - "i_ka": "f8", - "vm_from_pu": "f8", - "va_from_degree": "f8", - "vm_to_pu": "f8", - "va_to_degree": "f8", - "loading_percent": "f8", - }, - "_empty_res_line_dc": { - "p_from_mw": "f8", - "p_to_mw": "f8", - "pl_mw": "f8", - "i_from_ka": "f8", - "i_to_ka": "f8", - "i_ka": "f8", - "vm_from_pu": "f8", - "vm_to_pu": "f8", - "loading_percent": "f8", - }, - "_empty_res_trafo": { - "p_hv_mw": "f8", - "q_hv_mvar": "f8", - "p_lv_mw": "f8", - "q_lv_mvar": "f8", - "pl_mw": "f8", - "ql_mvar": "f8", - "i_hv_ka": "f8", - "i_lv_ka": "f8", - "vm_hv_pu": "f8", - "va_hv_degree": "f8", - "vm_lv_pu": "f8", - "va_lv_degree": "f8", - "loading_percent": "f8", - }, - "_empty_res_load": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_asymmetric_load": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_asymmetric_sgen": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_motor": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_sgen": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_shunt": { - "p_mw": "f8", - "q_mvar": "f8", - "vm_pu": "f8" - }, - "_empty_res_svc": { - "thyristor_firing_angle_degree": "f8", - "x_ohm": "f8", - "q_mvar": "f8", - "vm_pu": "f8", - "va_degree": "f8", - }, - "_empty_res_ssc": { - "q_mvar": "f8", - "vm_internal_pu": "f8", - "va_internal_degree": "f8", - "vm_pu": "f8", - "va_degree": "f8", - }, - "_empty_res_vsc": { - "p_mw": "f8", - "q_mvar": "f8", - "p_dc_mw": "f8", - "vm_internal_pu": "f8", - "va_internal_degree": "f8", - "vm_pu": "f8", - "va_degree": "f8", - "vm_internal_dc_pu": "f8", - "vm_dc_pu": "f8", - }, - "_empty_res_switch": { - "i_ka": "f8", - "loading_percent": "f8", - "p_from_mw": "f8", - "q_from_mvar": "f8", - "p_to_mw": "f8", - "q_to_mvar": "f8", - }, - "_empty_res_impedance": { - "p_from_mw": "f8", - "q_from_mvar": "f8", - "p_to_mw": "f8", - "q_to_mvar": "f8", - "pl_mw": "f8", - "ql_mvar": "f8", - "i_from_ka": "f8", - "i_to_ka": "f8", - }, - "_empty_res_tcsc": { - "thyristor_firing_angle_degree": "f8", - "x_ohm": "f8", - "p_from_mw": "f8", - "q_from_mvar": "f8", - "p_to_mw": "f8", - "q_to_mvar": "f8", - "pl_mw": "f8", - "ql_mvar": "f8", - "i_ka": "f8", - "vm_from_pu": "f8", - "va_from_degree": "f8", - "vm_to_pu": "f8", - "va_to_degree": "f8", - }, - "_empty_res_dcline": { - "p_from_mw": "f8", - "q_from_mvar": "f8", - "p_to_mw": "f8", - "q_to_mvar": "f8", - "pl_mw": "f8", - "vm_from_pu": "f8", - "va_from_degree": "f8", - "vm_to_pu": "f8", - "va_to_degree": "f8", - }, - "_empty_res_source_dc": { - "p_dc_mw": "f8" - }, - "_empty_res_load_dc": { - "p_dc_mw": "f8" - }, - "_empty_res_ward": { - "p_mw": "f8", - "q_mvar": "f8", - "vm_pu": "f8" - }, - "_empty_res_xward": { - "p_mw": "f8", - "q_mvar": "f8", - "vm_pu": "f8", - "va_internal_degree": "f8", - "vm_internal_pu": "f8", - }, - "_empty_res_trafo_3ph": { - "p_a_hv_mw": "f8", - "q_a_hv_mvar": "f8", - "p_b_hv_mw": "f8", - "q_b_hv_mvar": "f8", - "p_c_hv_mw": "f8", - "q_c_hv_mvar": "f8", - "p_a_lv_mw": "f8", - "q_a_lv_mvar": "f8", - "p_b_lv_mw": "f8", - "q_b_lv_mvar": "f8", - "p_c_lv_mw": "f8", - "q_c_lv_mvar": "f8", - "pl_a_mw": "f8", - "ql_a_mvar": "f8", - "pl_b_mw": "f8", - "ql_b_mvar": "f8", - "pl_c_mw": "f8", - "ql_c_mvar": "f8", - "i_a_hv_ka": "f8", - "i_a_lv_ka": "f8", - "i_b_hv_ka": "f8", - "i_b_lv_ka": "f8", - "i_c_hv_ka": "f8", - "i_c_lv_ka": "f8", - "loading_a_percent": "f8", - "loading_b_percent": "f8", - "loading_c_percent": "f8", - "loading_percent": "f8", - }, - "_empty_res_trafo3w": { - "p_hv_mw": "f8", - "q_hv_mvar": "f8", - "p_mv_mw": "f8", - "q_mv_mvar": "f8", - "p_lv_mw": "f8", - "q_lv_mvar": "f8", - "pl_mw": "f8", - "ql_mvar": "f8", - "i_hv_ka": "f8", - "i_mv_ka": "f8", - "i_lv_ka": "f8", - "vm_hv_pu": "f8", - "va_hv_degree": "f8", - "vm_mv_pu": "f8", - "va_mv_degree": "f8", - "vm_lv_pu": "f8", - "va_lv_degree": "f8", - "va_internal_degree": "f8", - "vm_internal_pu": "f8", - "loading_percent": "f8", - }, - "_empty_res_bus_3ph": { - "vm_a_pu": "f8", - "va_a_degree": "f8", - "vm_b_pu": "f8", - "va_b_degree": "f8", - "vm_c_pu": "f8", - "va_c_degree": "f8", - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - }, - "_empty_res_ext_grid_3ph": { - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - }, - "_empty_res_line_3ph": { - "p_a_from_mw": "f8", - "q_a_from_mvar": "f8", - "p_b_from_mw": "f8", - "q_b_from_mvar": "f8", - "p_c_from_mw": "f8", - "q_c_from_mvar": "f8", - "p_a_to_mw": "f8", - "q_a_to_mvar": "f8", - "p_b_to_mw": "f8", - "q_b_to_mvar": "f8", - "p_c_to_mw": "f8", - "q_c_to_mvar": "f8", - "pl_a_mw": "f8", - "ql_a_mvar": "f8", - "pl_b_mw": "f8", - "ql_b_mvar": "f8", - "pl_c_mw": "f8", - "ql_c_mvar": "f8", - "i_a_from_ka": "f8", - "i_a_to_ka": "f8", - "i_b_from_ka": "f8", - "i_b_to_ka": "f8", - "i_c_from_ka": "f8", - "i_c_to_ka": "f8", - "i_a_ka": "f8", - "i_b_ka": "f8", - "i_c_ka": "f8", - "i_n_from_ka": "f8", - "i_n_to_ka": "f8", - "i_n_ka": "f8", - "loading_a_percent": "f8", - "loading_b_percent": "f8", - "loading_c_percent": "f8", - }, - "_empty_res_asymmetric_load_3ph": { - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - }, - "_empty_res_asymmetric_sgen_3ph": { - "p_a_mw": "f8", - "q_a_mvar": "f8", - "p_b_mw": "f8", - "q_b_mvar": "f8", - "p_c_mw": "f8", - "q_c_mvar": "f8", - }, - "_empty_res_storage": { - "p_mw": "f8", - "q_mvar": "f8" - }, - "_empty_res_storage_3ph": { - "p_a_mw": "f8", - "p_b_mw": "f8", - "p_c_mw": "f8", - "q_a_mvar": "f8", - "q_b_mvar": "f8", - "q_c_mvar": "f8", - }, - "_empty_res_gen": { - "p_mw": "f8", - "q_mvar": "f8", - "va_degree": "f8", - "vm_pu": "f8", - }, "_empty_res_protection": { "switch_id": "f8", "prot_type": dtype(object), @@ -795,21 +142,23 @@ def get_structure_dict() -> dict: "act_param": dtype(object), "act_param_val": "f8", "trip_melt_time_s": "f8", - }, + }, # TODO: convert to pandera # internal "_ppc": None, "_ppc0": None, "_ppc1": None, "_ppc2": None, "_is_elements": None, - "_pd2ppc_lookups": [{ - "bus": None, - "bus_dc": None, - "ext_grid": None, - "gen": None, - "branch": None, - "branch_dc": None, - }], + "_pd2ppc_lookups": [ + { + "bus": None, + "bus_dc": None, + "ext_grid": None, + "gen": None, + "branch": None, + "branch_dc": None, + } + ], "version": __version__, "format_version": __format_version__, "converged": False, @@ -817,7 +166,8 @@ def get_structure_dict() -> dict: "name": "", "f_hz": 50.0, "sn_mva": 1, - } + }) + return dtypes_dict def get_std_type_structure_dict() -> dict: @@ -826,88 +176,10 @@ def get_std_type_structure_dict() -> dict: """ return { # structure data - "line": { - "r_ohm_per_km": "f8", - "r0_ohm_per_km": "f8", - "x_ohm_per_km": "f8", - "x0_ohm_per_km": "f8", - "c_nf_per_km": "f8", - "c0_nf_per_km": "f8", - "g_us_per_km": "f8", - "g0_us_per_km": "f8", - "max_i_ka": "f8", - "type": dtype(object), - "q_mm2": "f8", - "alpha": "f8", - "voltage_rating": dtype(object), - }, - "line_dc": { - "r_ohm_per_km": "f8", - "r0_ohm_per_km": "f8", - "g_us_per_km": "f8", - "g0_us_per_km": "f8", - "max_i_ka": "f8", - "type": dtype(object), - "q_mm2": "f8", - "alpha": "f8", - "voltage_rating": "f8", - }, - "trafo": { - "sn_mva": "f8", - "vn_hv_kv": "f8", - "vn_lv_kv": "f8", - "vk_percnet": "f8", - "vk0_percent": "f8", - "vkr_percent": "f8", - "vkr0_percent": "f8", - "pfe_kw": "f8", - "i0_percent": "f8", - "shift_degree": "f8", - "vector_group": dtype(object), - "tap_side": dtype(object), - "tap_neutral": "f8", - "tap_min": "f8", - "tap_max": "f8", - "tap_step_degree": "f8", - "tap_step_percent": "f8", - "tap_change_type": dtype(object), - "trafo_characteristic_table": "bool", - }, - "trafo3w": { - "sn_hv_mva": "f8", - "sn_mv_mva": "f8", - "sn_lv_mva": "f8", - "vn_hv_kv": "f8", - "vn_mv_kv": "f8", - "vn_lv_kv": "f8", - "vk_hv_percent": "f8", - "vk_mv_percent": "f8", - "vk_lv_percent": "f8", - "vk0_hv_percent": "f8", - "vk0_mv_percent": "f8", - "vk0_lv_percent": "f8", - "vkr_hv_percent": "f8", - "vkr_mv_percent": "f8", - "vkr_lv_percent": "f8", - "vkr0_hv_percent": "f8", - "vkr0_mv_percent": "f8", - "vkr0_lv_percent": "f8", - "pfe_kw": "f8", - "i0_percent": "f8", - "shift_mv_degree": "f8", - "shift_lv_degree": "f8", - "tap_side": dtype(object), - "tap_neutral": "f8", - "tap_min": "f8", - "tap_max": "f8", - "tap_step_percent": "f8", - "tap_step_degree": "f8", - "tap_pos": "f8", - "tap_at_star_point": "bool", - "tap_changer_type": dtype(object), - "id_characteristic_table": pd.Int64Dtype(), - "vector_group": dtype(object), - }, + "line": get_dtypes(line_schema), + "line_dc": get_dtypes(line_dc_schema), + "trafo": get_dtypes(trafo_schema), + "trafo3w": get_dtypes(trafo3w_schema), "fuse": { "fuse_type": dtype(object), "i_rated_a": "f8", @@ -919,5 +191,3 @@ def get_std_type_structure_dict() -> dict: "x_total": dtype(object), }, } - - diff --git a/pandapower/networks/mv_oberrhein.py b/pandapower/networks/mv_oberrhein.py index 6658948fce..5933640973 100644 --- a/pandapower/networks/mv_oberrhein.py +++ b/pandapower/networks/mv_oberrhein.py @@ -57,12 +57,8 @@ def mv_oberrhein( """ if include_substations: net = from_json(os.path.join(pp_dir, "networks", "mv_oberrhein_substations.json"), **kwargs) - # geo.convert_epsg_bus_geodata(net, epsg_out=4326, epsg_in=31467) - # geo.convert_geodata_to_geojson(net, lonlat=False) else: net = from_json(os.path.join(pp_dir, "networks", "mv_oberrhein.json"), **kwargs) - # geo.convert_epsg_bus_geodata(net, epsg_out=4326, epsg_in=31467) - # geo.convert_geodata_to_geojson(net, lonlat=False) net.load.q_mvar = np.tan(np.arccos(cosphi_load)) * net.load.p_mw net.sgen.q_mvar = np.tan(np.arccos(cosphi_pv)) * net.sgen.p_mw @@ -93,12 +89,8 @@ def mv_oberrhein( runpp(net1) net0.name = 'MV Oberrhein 0' net1.name = 'MV Oberrhein 1' - # TODO: this should be added to the initial data not converted here. - # geo.convert_geodata_to_geojson(net0) - # geo.convert_geodata_to_geojson(net1) return net0, net1 runpp(net) net.name = 'MV Oberrhein' - # geo.convert_geodata_to_geojson(net) return net diff --git a/pandapower/opf/validate_opf_input.py b/pandapower/opf/validate_opf_input.py index 1021da83f1..815d26623d 100644 --- a/pandapower/opf/validate_opf_input.py +++ b/pandapower/opf/validate_opf_input.py @@ -9,8 +9,7 @@ def _check_necessary_opf_parameters(net, logger): 'sgen': pd.Series(['min_p_mw', 'max_p_mw', 'min_q_mvar', 'max_q_mvar']), 'load': pd.Series(['min_p_mw', 'max_p_mw', 'min_q_mvar', 'max_q_mvar']), 'storage': pd.Series(['min_p_mw', 'max_p_mw', 'min_q_mvar', 'max_q_mvar']), - 'dcline': pd.Series(['max_p_mw', 'min_q_from_mvar', 'min_q_to_mvar', 'max_q_from_mvar', - 'max_q_to_mvar'])} + 'dcline': pd.Series(['max_p_mw', 'min_q_from_mvar', 'min_q_to_mvar', 'max_q_from_mvar', 'max_q_to_mvar'])} missing_val = [] error = False for element_type, columns in opf_col.items(): @@ -22,8 +21,7 @@ def _check_necessary_opf_parameters(net, logger): else: if "controllable" in net[element_type].columns: net[element_type].controllable = net[element_type].controllable.fillna(element_type == 'gen') - controllables = net[element_type].index[net[element_type].controllable.astype( - bool)] + controllables = net[element_type].index[net[element_type].controllable.astype(bool)] else: controllables = net[element_type].index if element_type == 'gen' else [] @@ -35,37 +33,37 @@ def _check_necessary_opf_parameters(net, logger): net[element_type][col].loc[controllables].isnull().any()] if len(missing_col): - if element_type != "ext_grid": - logger.error("These columns are missing in " + element_type + ": " + - str(missing_col)) + if element_type == "ext_grid": # no error due to missing columns + logger.debug( + f"These missing columns in {element_type} are considered in OPF as +- 1000 TW.: {missing_col}" + ) + else: + logger.error( + f"These columns are missing in {element_type}: {missing_col}" + ) error = True - else: # "ext_grid" -> no error due to missing columns at ext_grid - logger.debug("These missing columns in ext_grid are considered in OPF as " + - "+- 1000 TW.: " + str(missing_col)) # determine missing values if len(na_col): missing_val.append(element_type) if missing_val: - logger.info("These elements have missing power constraint values, which are considered " + - "in OPF as +- 1000 TW: " + str(missing_val)) + logger.info( + f"These elements have missing power constraint values, " + f"which are considered in OPF as +- 1000 TW: {missing_val}" + ) # voltage limits: no error due to missing voltage limits if 'min_vm_pu' in net.bus.columns: if net.bus.min_vm_pu.isnull().any(): - logger.info("There are missing bus.min_vm_pu values, which are considered in OPF as " + - "0.0 pu.") + logger.info("There are missing bus.min_vm_pu values, which are considered in OPF as 0.0 pu.") else: - logger.info("'min_vm_pu' is missing in bus table. In OPF these limits are considered as " + - "0.0 pu.") + logger.info("'min_vm_pu' is missing in bus table. In OPF these limits are considered as 0.0 pu.") if 'max_vm_pu' in net.bus.columns: if net.bus.max_vm_pu.isnull().any(): - logger.info("There are missing bus.max_vm_pu values, which are considered in OPF as " + - "2.0 pu.") + logger.info("There are missing bus.max_vm_pu values, which are considered in OPF as 2.0 pu.") else: - logger.info("'max_vm_pu' is missing in bus table. In OPF these limits are considered as " + - "2.0 pu.") + logger.info("'max_vm_pu' is missing in bus table. In OPF these limits are considered as 2.0 pu.") if error: raise KeyError("OPF parameters are not set correctly. See error log.") diff --git a/pandapower/pd2ppc.py b/pandapower/pd2ppc.py index 71055050aa..6218963983 100644 --- a/pandapower/pd2ppc.py +++ b/pandapower/pd2ppc.py @@ -161,7 +161,7 @@ def _pd2ppc(net, sequence=None, **kwargs): # Adds P and Q for loads / sgens in ppc['bus'] (PQ nodes) if mode == "sc": _add_ext_grid_sc_impedance(net, ppc) - # Generator impedance are seperately added in sc module + # Generator impedance are separately added in sc module _add_motor_impedances_ppc(net, ppc) if net._options.get("use_pre_fault_voltage", False): _add_load_sc_impedances_ppc(net, ppc) # add SC impedances for loads @@ -171,10 +171,10 @@ def _pd2ppc(net, sequence=None, **kwargs): # adds P and Q for shunts, wards and xwards (to PQ nodes) _calc_shunts_and_add_on_ppc(net, ppc) - # adds auxilary buses for open switches at branches + # adds auxiliary buses for open switches at branches _switch_branches(net, ppc) - # Adds auxilary buses for in service lines with out of service buses. + # Adds auxiliary buses for in service lines with out of service buses. # Also deactivates lines if they are connected to two out of service buses _branches_with_oos_buses(net, ppc) _branches_with_oos_buses(net, ppc, True) diff --git a/pandapower/pd2ppc_zero.py b/pandapower/pd2ppc_zero.py index 85951a7acf..024793dafe 100644 --- a/pandapower/pd2ppc_zero.py +++ b/pandapower/pd2ppc_zero.py @@ -178,7 +178,7 @@ def _add_trafo_sc_impedance_zero(net, ppc, trafo_df=None, k_st=None): ) for vector_group, trafos in trafo_df.groupby("vector_group"): - # TODO Roman: check this/expand this + # TODO: check this/expand this ppc_idx = trafos["_ppc_idx"].values.astype(np.int64) if vector_group.lower() in ["yy", "yd", "dy", "dd"]: @@ -367,7 +367,6 @@ def _add_trafo_sc_impedance_zero(net, ppc, trafo_df=None, k_st=None): if trafo_model == "pi": y = 1 / (z0_mag + z0_k).astype(complex) # pi model else: - # y = (YAB_AN + YBN).astype(complex) # T model y = (YAB + YAB_BN + YBN).astype(complex) # T model y_asym = y * in_service.values * 2 @@ -433,7 +432,6 @@ def _add_gen_sc_impedance_zero(net, ppc): eg_buses_ppc = bus_lookup[eg_buses] y0_gen = 1 / (1e3 + 1e3 * 1j) - # buses, gs, bs = aux._sum_by_group(eg_buses_ppc, y0_gen.real, y0_gen.imag) ppc["bus"][eg_buses_ppc, GS] += y0_gen.real ppc["bus"][eg_buses_ppc, BS] += y0_gen.imag @@ -559,7 +557,7 @@ def _add_impedance_sc_impedance_zero(net, ppc): def _add_trafo3w_sc_impedance_zero(net, ppc): - # TODO Roman: check this/expand this + # TODO: check this/expand this branch_lookup = net["_pd2ppc_lookups"]["branch"] if "trafo3w" not in branch_lookup: return diff --git a/pandapower/pf/ppci_variables.py b/pandapower/pf/ppci_variables.py index d264d065c7..a379a481a9 100644 --- a/pandapower/pf/ppci_variables.py +++ b/pandapower/pf/ppci_variables.py @@ -13,7 +13,6 @@ def _get_pf_variables_from_ppci(ppci, vsc_ref=False): ## default arguments if ppci is None: raise ValueError('ppci is empty') - # ppopt = ppoption(ppopt) # get data for calc bus, gen, vsc = ppci["bus"], ppci["gen"], ppci["vsc"] diff --git a/pandapower/pf/runpp_3ph.py b/pandapower/pf/runpp_3ph.py index 2eb138259b..fee8344746 100644 --- a/pandapower/pf/runpp_3ph.py +++ b/pandapower/pf/runpp_3ph.py @@ -54,9 +54,12 @@ def _get_elements(params, net, element, phase, typ): elm = net[element].values # # Trying to find the column no for using numpy filters for active loads scaling = net[element].columns.get_loc("scaling") - typ_col = net[element].columns.get_loc("type") # Type = Delta or Wye load - # active wye or active delta row selection - active = (net["_is_elements"][element]) & (elm[:, typ_col] == typ) + if "type" in net[element]: + typ_col = net[element].columns.get_loc("type") # Type = Delta or Wye load + # active wye or active delta row selection + active = (net["_is_elements"][element]) & (elm[:, typ_col] == typ) + else: + active = [] bus = [net[element].columns.get_loc("bus")] if len(elm): if element == 'load' or element == 'sgen': diff --git a/pandapower/plotting/geo.py b/pandapower/plotting/geo.py index 5673d3f1b4..b856092ac0 100644 --- a/pandapower/plotting/geo.py +++ b/pandapower/plotting/geo.py @@ -281,7 +281,7 @@ def update_props(r: pd.Series) -> None: if element: props: dict = {} for table in [name, f"res_{name}"]: - if table not in net.keys(): + if table not in net: continue tempdf = net[table].copy(deep=True) diff --git a/pandapower/plotting/plotly/simple_plotly.py b/pandapower/plotting/plotly/simple_plotly.py index 0f1dc86fe6..89a38e0762 100644 --- a/pandapower/plotting/plotly/simple_plotly.py +++ b/pandapower/plotting/plotly/simple_plotly.py @@ -227,7 +227,7 @@ def _simple_plotly_generic(net, respect_separators, use_branch_geodata, branch_w settings = settings_defaults | settings if settings else {} # add missing settings to settings dict - if len(net[node_element]["geo"].dropna()) == 0: + if "geo" not in net[node_element] or len(net[node_element]["geo"].dropna()) == 0: logger.warning( "No or insufficient geodata available --> Creating artificial coordinates. This may take some time..." ) diff --git a/pandapower/plotting/plotting_toolbox.py b/pandapower/plotting/plotting_toolbox.py index a8fd066a42..cdc5ada02e 100644 --- a/pandapower/plotting/plotting_toolbox.py +++ b/pandapower/plotting/plotting_toolbox.py @@ -38,6 +38,7 @@ def _get_coords_from_geojson(gj_str): return ast.literal_eval(m) return None + def get_collection_sizes(net, bus_size=1.0, ext_grid_size=1.0, trafo_size=1.0, load_size=1.0, sgen_size=1.0, switch_size=2.0, switch_distance=1.0, gen_size=1.0): """ diff --git a/pandapower/plotting/simple_plot.py b/pandapower/plotting/simple_plot.py index 039592e75f..42fb3ea31c 100644 --- a/pandapower/plotting/simple_plot.py +++ b/pandapower/plotting/simple_plot.py @@ -11,7 +11,6 @@ try: import matplotlib.pyplot as plt - MATPLOTLIB_INSTALLED = True except ImportError: MATPLOTLIB_INSTALLED = False @@ -38,10 +37,92 @@ logger = logging.getLogger(__name__) +def bus_info(bus): + return ("bus", bus) + + +def line_info(line): + return ("line", line) + + +def trafo_info(idx): + return ("trafo", idx) + + +def trafo3w_info(idx): + return ("trafo3w", idx) + + +def hover(event, ax, net, hover_text): + """ + Update the hover text in an interactive pandapower plot based on the mouse position. + + Expects collections to have an `info` attribute containing a list of + (element, index) tuples, e.g. ("bus", 3) or ("line", 5). + + Parameters + ---------- + event : matplotlib.backend_bases.MouseEvent + Mouse-move event from Matplotlib. + ax : matplotlib.axes.Axes + Axes object containing the collections. + net : pp.pandapowerNet + pandapower network with DataFrames (bus, line, trafo, trafo3w, ...). + hover_text : matplotlib.text.Text + Text artist whose content, position and visibility are updated. + """ + fig = ax.figure + visible = hover_text.get_visible() + + if event.inaxes is not ax: + if visible: + hover_text.set_visible(False) + fig.canvas.draw_idle() + return + + for collection in ax.collections: + info = getattr(collection, "info", None) + if not info: + continue + + contains, props = collection.contains(event) + if not contains or "ind" not in props or len(props["ind"]) == 0: + continue + + coll_idx = props["ind"][0] + element_info = info[coll_idx] + + if isinstance(element_info, tuple) and len(element_info) == 2: + element, idx = element_info + else: + element, idx = str(element_info), None + + df = getattr(net, element, None) + + if df is not None and idx is not None and idx in df.index and "name" in df.columns: + name = df.at[idx, "name"] + hover_info = f"{element}: {name} | Index: {idx}" + elif idx is not None: + hover_info = f"{element} | Index: {idx}" + else: + hover_info = str(element_info) + + # text and position + hover_text.set_text(hover_info) + hover_text.set_position((event.xdata, event.ydata)) + hover_text.set_visible(True) + fig.canvas.draw_idle() + return + + if visible: + hover_text.set_visible(False) + fig.canvas.draw_idle() + + def simple_plot( net: pandapowerNet, respect_switches: bool = False, - line_width: float = 1.0, + line_width: float = 2.0, bus_size: float = 1.0, ext_grid_size: float = 1.0, trafo_size: float = 1.0, @@ -56,11 +137,11 @@ def simple_plot( switch_distance: float = 1.0, plot_line_switches: bool = False, scale_size: bool = True, - bus_color="b", + bus_color="#1c3f52", line_color="grey", dcline_color="c", trafo_color="k", - ext_grid_color="y", + ext_grid_color="#179c7d", switch_color="k", library="igraph", show_plot: bool = True, @@ -71,6 +152,12 @@ def simple_plot( line_dc_color="c", vsc_size: float = 4.0, vsc_color="orange", + highlight_buses=None, + highlight_lines=None, + enable_hover=True, + highlight_bus_size_factor=1.5, + highlight_line_width_factor=2.0, + highlight_color="#f58220" ): """ Plots a pandapower network as simple as possible. If no geodata is available, artificial @@ -108,7 +195,12 @@ def simple_plot( to use igraph package or "networkx" to use networkx package. show_plot (bool, True): Shows plot at the end of plotting ax (object, None): matplotlib axis to plot to - + highlight_buses (iterable, None): buses, to highlight + highlight_lines (iterable, None): lines to highlight + enable_hover (bool, True): enable hovering functionality + highlight_bus_size_factor (float, 1.5): bus_size for highlighted buses + highlight_line_width_factor (float, 2.0): line_width for highlighted lines + highlight_color (str, "r"): color for highlighted elements Returns: axes of figure """ @@ -126,9 +218,11 @@ def simple_plot( respect_switches = False # create geocoord if none are available - if (len(net.line.geo) == 0 and len(net.bus.geo) == 0) or (net.line.geo.isna().any() and net.bus.geo.isna().any()): - logger.warning("No or insufficient geodata available --> Creating artificial coordinates." + - " This may take some time") + if (len(net.line.geo) == 0 and len(net.bus.geo) == 0) or ( + net.line.geo.isna().any() and net.bus.geo.isna().any()): + logger.warning( + "No or insufficient geodata available --> Creating artificial coordinates." + + " This may take some time") create_generic_coordinates(net, respect_switches=respect_switches, library=library) if scale_size: @@ -153,10 +247,20 @@ def simple_plot( switch_distance = sizes["switch_distance"] gen_size = sizes["gen"] - # create bus collections to plot - bc = create_bus_collection( - net, net.bus.index, size=bus_size, color=bus_color, zorder=10 - ) + bc = create_bus_collection(net, net.bus.index, size=bus_size, color=bus_color, + zorder=10, infofunc=bus_info) + collections = [bc] + + if highlight_buses is not None: + hl_buses_idx = list(set(highlight_buses) & set(net.bus.index)) + if len(hl_buses_idx): + hbc = create_bus_collection( + net, hl_buses_idx, + size=bus_size * highlight_bus_size_factor, + color=highlight_color, + zorder=bc.zorder + 1 if hasattr(bc, "zorder") else 11, + infofunc=bus_info) + collections.append(hbc) # if bus geodata is available, but no line geodata use_bus_geodata = len(net.line.geo.dropna()) == 0 @@ -170,22 +274,37 @@ def simple_plot( plot_dclines = net.dcline.in_service plot_lines_dc = net.line_dc.loc[net.line_dc.in_service].index - # create line collections lc = create_line_collection( net, plot_lines, color=line_color, linewidths=line_width, use_bus_geodata=use_bus_geodata, - ) - collections = [bc, lc] + infofunc=line_info) + collections.append(lc) + + if highlight_lines is not None: + hl_lines_idx = list(set(highlight_lines) & set(plot_lines)) + if len(hl_lines_idx): + hlc = create_line_collection( + net, + hl_lines_idx, + color=highlight_color, + linewidths=line_width * highlight_line_width_factor, + use_bus_geodata=use_bus_geodata, + infofunc=line_info) + collections.append(hlc) # create dcline collections if len(net.dcline) > 0: dclc = create_dcline_collection( - net, plot_dclines, color=dcline_color, linewidths=line_width + net, + plot_dclines, + color=dcline_color, + linewidths=line_width ) collections.append(dclc) + # create bus dc collection if len(net.bus_dc) > 0: bc_dc = create_bus_collection( @@ -197,12 +316,14 @@ def simple_plot( bus_table="bus_dc", ) collections.append(bc_dc) + # create VSC collection if len(net.vsc) > 0: vsc_ac = create_vsc_collection( net, net.vsc.index, size=vsc_size, color=vsc_color, zorder=12 ) collections.append(vsc_ac) + # create line_dc collections if len(net.line_dc) > 0: lc_dc = create_line_collection( @@ -216,7 +337,6 @@ def simple_plot( collections.append(lc_dc) # create ext_grid collections - # eg_buses_with_geo_coordinates = set(net.ext_grid.bus.values) & set(net.bus_geodata.index) if len(net.ext_grid) > 0: sc = create_ext_grid_collection( net, @@ -236,7 +356,11 @@ def simple_plot( ] if len(trafo_buses_with_geo_coordinates) > 0: tc = create_trafo_collection( - net, trafo_buses_with_geo_coordinates, color=trafo_color, size=trafo_size + net, + trafo_buses_with_geo_coordinates, + color=trafo_color, + size=trafo_size, + infofunc=trafo_info ) collections.append(tc) @@ -249,9 +373,8 @@ def simple_plot( trafo3w.lv_bus in net.bus.geo.index ] if len(trafo3w_buses_with_geo_coordinates) > 0: - tc = create_trafo3w_collection( - net, trafo3w_buses_with_geo_coordinates, color=trafo_color - ) + tc = create_trafo3w_collection(net, trafo3w_buses_with_geo_coordinates, + color=trafo_color, infofunc=trafo3w_info) collections.append(tc) if plot_line_switches and len(net.switch): @@ -298,6 +421,21 @@ def simple_plot( collections.append(bsc) ax = draw_collections(collections, ax=ax) + + if enable_hover: + fig = ax.figure + hover_text = ax.text(0, 0, "", fontsize=12, fontweight="bold", color='white', + ha='center', va='center', zorder=99, + bbox={"boxstyle": "round", + "facecolor": '#179c7d', + "alpha": 1, + "edgecolor": 'white'}) + hover_text.set_visible(False) + fig.canvas.mpl_connect( + "motion_notify_event", + lambda event: hover(event, ax, net, hover_text) + ) + if show_plot: if not MATPLOTLIB_INSTALLED: soft_dependency_error( @@ -316,8 +454,14 @@ def calculate_unique_angles(net: pandapowerNet) -> dict[int, dict[str, dict[str, :returns: a dictionary containing layout information for each patch at bus, load has only one patch at bottom. :rtype: dict[int, dict[str, Union[dict[str, float], float]]] """ - sgen_counts = net.sgen.groupby(['bus', 'type'], dropna=False).size().unstack(fill_value=0) - gen_counts = net.gen.groupby(['bus', 'type'], dropna=False).size().unstack(fill_value=0) + if 'type' in net.sgen: + sgen_counts = net.sgen.groupby(['bus', 'type'], dropna=False).size().unstack(fill_value=0) + else: + sgen_counts = pd.DataFrame(net.sgen.groupby(['bus'], dropna=False).size()) + if 'type' in net.gen: + gen_counts = net.gen.groupby(['bus', 'type'], dropna=False).size().unstack(fill_value=0) + else: + gen_counts = pd.DataFrame(net.gen.groupby(['bus'], dropna=False).size()) loads = pd.Series(1, index=net.load.bus.unique(), name='load') patch_counts = pd.concat([sgen_counts, gen_counts, loads], axis=1).fillna(0) @@ -333,7 +477,7 @@ def calculate_unique_angles(net: pandapowerNet) -> dict[int, dict[str, dict[str, for c, v in row.items(): _type: str if v > 0: - if isinstance(c, float) and math.isnan(c): + if pd.isna(c) or isinstance(c, int): _type = "none" else: _type = str(c) diff --git a/pandapower/powerflow.py b/pandapower/powerflow.py index bb060a241a..f6935a81fa 100644 --- a/pandapower/powerflow.py +++ b/pandapower/powerflow.py @@ -47,8 +47,7 @@ def _powerflow(net, **kwargs): if net["_options"]["voltage_depend_loads"] and algorithm not in ['nr', 'bfsw'] and not ( allclose(concatenate((net.load.const_z_p_percent.values, net.load.const_z_q_percent.values)), 0) and allclose(concatenate((net.load.const_z_p_percent.values, net.load.const_z_q_percent.values)), 0)): - logger.error(("pandapower powerflow does not support voltage depend loads for algorithm " - "'%s'!") % algorithm) + logger.error(f"pandapower powerflow does not support voltage depend loads for algorithm '{algorithm}'!") # clear lookups net._pd2ppc_lookups = {"bus": array([], dtype=int64), "bus_dc": array([], dtype=int64), @@ -143,7 +142,7 @@ def _run_pf_algorithm(ppci, options, **kwargs): if pq.shape[0] == 0 and pv.shape[0] == 0 and not options['distributed_slack'] \ and len(ppci["svc"]) == 0 and len(ppci["tcsc"]) == 0 and len(ppci["ssc"]) == 0 and len(ppci["vsc"]) == 0: # ommission not correct if distributed slack is used or facts devices are present - result = _bypass_pf_and_set_results(ppci, options) + result = _bypass_pf_and_set_results(ppci) elif algorithm == 'bfsw': # forward/backward sweep power flow algorithm result = _run_bfswpf(ppci, options, **kwargs) elif algorithm in ['nr', 'iwamoto_nr']: @@ -181,7 +180,7 @@ def _ppci_to_net(result, net): _clean_up(net) -def _bypass_pf_and_set_results(ppci, options): +def _bypass_pf_and_set_results(ppci): Ybus, Yf, Yt = makeYbus_pypower(ppci["baseMVA"], ppci["bus"], ppci["branch"]) baseMVA, bus, gen, branch, svc, tcsc, ssc, vsc, ref, *_, ref_gens = _get_pf_variables_from_ppci(ppci) V = ppci["bus"][:, VM] diff --git a/pandapower/protection/utility_functions.py b/pandapower/protection/utility_functions.py index bd5bc17fea..f3d1dab47d 100644 --- a/pandapower/protection/utility_functions.py +++ b/pandapower/protection/utility_functions.py @@ -14,6 +14,7 @@ from pandapower.auxiliary import pandapowerNet from pandapower.topology.create_graph import create_nxgraph +from pandapower.create._utils import add_column_to_df from pandapower.create import create_bus, create_line_from_parameters from pandapower.plotting.collections import create_annotation_collection, create_line_collection, \ create_bus_collection, create_line_switch_collection, draw_collections, create_trafo_collection, \ @@ -95,12 +96,15 @@ def create_sc_bus(net_copy, sc_line_id, sc_fraction): # sim bench grids if 's_sc_max_mva' not in net.ext_grid: print('input s_sc_max_mva or taking 1000') + add_column_to_df(net, "ext_grid", "s_sc_max_mva") net.ext_grid['s_sc_max_mva'] = 1000 if 'rx_max' not in net.ext_grid: print('input rx_max or taking 0.1') + add_column_to_df(net, "ext_grid", "rx_max") net.ext_grid['rx_max'] = 0.1 if 'k' not in net.sgen and len(net.sgen) != 0: print('input Ratio of nominal current to short circuit current- k or taking k=1') + add_column_to_df(net, "sgen", "k") net.sgen['k'] = 1 # set new lines @@ -380,8 +384,6 @@ def plot_tripped_grid(net, trip_decisions, sc_location, bus_size=0.055, plot_ann geojson.utils.coords(geojson.loads(net.bus.geo.at[bus])) for bus in bus_list ] - # TODO: - # place annotations on middle of the line line_geo_x = (bus_coords[0][0] + bus_coords[1][0]) / 2 line_geo_y = ((bus_coords[0][1] + bus_coords[1][1]) / 2) + 0.05 @@ -413,7 +415,6 @@ def plot_tripped_grid(net, trip_decisions, sc_location, bus_size=0.055, plot_ann # placing bus bus_index = [(x[0] - 0.11, x[1] + 0.095) for x in bus_geodata] - # TODO: bus_annotate = create_annotation_collection(texts=bus_text, coords=bus_index, size=0.06, prop=None) collection.append(bus_annotate) diff --git a/pandapower/pypower/idx_brch_dc.py b/pandapower/pypower/idx_brch_dc.py index 4e1862d73d..09aaaa8235 100644 --- a/pandapower/pypower/idx_brch_dc.py +++ b/pandapower/pypower/idx_brch_dc.py @@ -56,8 +56,8 @@ DC_PT = 10 # real power injected at "to" bus end (MW) DC_IT = 11 # current injected at "to" bus end (p.u.) -DC_BR_R_ASYM = 12 # todo Roman check if necessary -DC_BR_X_ASYM = 13 # todo Roman check if necessary +DC_BR_R_ASYM = 12 # todo check if necessary +DC_BR_X_ASYM = 13 # todo check if necessary DC_TDPF = 14 ### TDPF not implemented for DC lines diff --git a/pandapower/pypower/idx_ssc.py b/pandapower/pypower/idx_ssc.py index 7f08e61210..0e81a8b5d3 100644 --- a/pandapower/pypower/idx_ssc.py +++ b/pandapower/pypower/idx_ssc.py @@ -36,7 +36,7 @@ SSC_SET_VM_PU = 4 SSC_STATUS = 5 # initial branch status, 1 - in service, 0 - out of service SSC_CONTROLLABLE = 6 -SSC_X_CONTROL_VA = 7 # va degrees ## check with roman +SSC_X_CONTROL_VA = 7 # va degrees TODO: check SSC_X_CONTROL_VM = 8 # (p.u) vm SSC_Q = 9 # result for Q diff --git a/pandapower/pypower/pipsopf_solver.py b/pandapower/pypower/pipsopf_solver.py index 9005730cb1..09ef280dc6 100644 --- a/pandapower/pypower/pipsopf_solver.py +++ b/pandapower/pypower/pipsopf_solver.py @@ -11,19 +11,19 @@ """Solves AC optimal power flow using PIPS. """ -from numpy import flatnonzero as find, ones, zeros, inf, pi, exp, conj, r_, int64 -from pandapower.pypower.idx_brch import F_BUS, T_BUS, RATE_A, PF, QF, PT, QT, MU_SF, MU_ST -from pandapower.pypower.idx_bus import BUS_TYPE, REF, VM, VA, MU_VMAX, MU_VMIN, LAM_P, LAM_Q -from pandapower.pypower.idx_cost import MODEL, PW_LINEAR, NCOST -from pandapower.pypower.idx_gen import GEN_BUS, PG, QG, VG, MU_PMAX, MU_PMIN, MU_QMAX, MU_QMIN +from numpy import conj, exp, inf, int64, ones, pi, r_, zeros +from numpy import flatnonzero as find + +from pandapower.pypower.idx_brch import F_BUS, MU_SF, MU_ST, PF, PT, QF, QT, RATE_A, T_BUS +from pandapower.pypower.idx_bus import BUS_TYPE, LAM_P, LAM_Q, MU_VMAX, MU_VMIN, REF, VA, VM +from pandapower.pypower.idx_cost import MODEL, NCOST, PW_LINEAR +from pandapower.pypower.idx_gen import GEN_BUS, MU_PMAX, MU_PMIN, MU_QMAX, MU_QMIN, PG, QG, VG from pandapower.pypower.makeYbus import makeYbus from pandapower.pypower.opf_consfcn import opf_consfcn from pandapower.pypower.opf_costfcn import opf_costfcn - -from pandapower.pypower.util import sub2ind - from pandapower.pypower.opf_hessfcn import opf_hessfcn from pandapower.pypower.pips import pips +from pandapower.pypower.util import sub2ind def pipsopf_solver(om, ppopt, out_opt=None): @@ -115,7 +115,7 @@ def pipsopf_solver(om, ppopt, out_opt=None): Ybus, Yf, Yt = makeYbus(baseMVA, bus, branch) ## try to select an interior initial point if init is not available from a previous powerflow - if init != "pf": + if init not in ("pf", "results"): ll, uu = xmin.copy(), xmax.copy() ll[xmin == -inf] = -1e10 ## replace Inf with numerical proxies uu[xmax == inf] = 1e10 diff --git a/pandapower/results.py b/pandapower/results.py index 5e270a2840..34c329f980 100644 --- a/pandapower/results.py +++ b/pandapower/results.py @@ -163,8 +163,9 @@ def empty_res_element(net, element, suffix=None): if res_empty_element in net: net[res_element] = net[res_empty_element].copy() else: - net[res_element] = pd.DataFrame(columns=pd.Index([], dtype=object), - index=pd.Index([], dtype=np.int64)) + net[res_element] = pd.DataFrame( + columns=pd.Index([], dtype=object), index=pd.Index([], dtype=np.int64) + ) def init_element(net, element, suffix=None): @@ -173,9 +174,7 @@ def init_element(net, element, suffix=None): if len(index): # init empty dataframe if res_empty_element in net: - columns = net[res_empty_element].columns - net[res_element] = pd.DataFrame(np.nan, index=index, - columns=columns, dtype='float') + net[res_element] = pd.DataFrame(np.nan, index=index, columns=net[res_empty_element].columns, dtype='float') else: net[res_element] = pd.DataFrame(index=index, dtype='float') else: diff --git a/pandapower/results_branch.py b/pandapower/results_branch.py index 66aaf638c0..962dc48bd6 100644 --- a/pandapower/results_branch.py +++ b/pandapower/results_branch.py @@ -62,10 +62,6 @@ def _get_branch_results_3ph(net, ppc0, ppc1, ppc2, bus_lookup_aranged, pq_buses) I012_f, _, V012_f, I012_t, _, V012_t = _get_branch_flows_3ph(ppc0, ppc1, ppc2) _get_line_results_3ph(net, ppc0, ppc1, ppc2, I012_f, V012_f, I012_t, V012_t) _get_trafo_results_3ph(net, ppc1, ppc2, I012_f, V012_f, I012_t, V012_t) - # _get_trafo3w_results(net, ppc, s_ft, i_ft) - # _get_impedance_results(net, ppc, i_ft) - # _get_xward_branch_results(net, ppc, bus_lookup_aranged, pq_buses) - # _get_switch_results(net, i_ft) def _get_branch_flows(ppc): @@ -614,7 +610,6 @@ def _get_tcsc_results(net, ppc, suffix=None): # zeros_ # write to impedance - # todo for suffix not None res_tcsc_df = net["res_tcsc"] if suffix is None else net["res_tcsc%s" % suffix] res_tcsc_df.loc[:, "thyristor_firing_angle_degree"] = np.rad2deg(ppc["tcsc"][f:t, TCSC_THYRISTOR_FIRING_ANGLE].real) diff --git a/pandapower/results_bus.py b/pandapower/results_bus.py index d3baff8159..73d7c6bc58 100644 --- a/pandapower/results_bus.py +++ b/pandapower/results_bus.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from typing import Any # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. @@ -140,51 +141,53 @@ def _get_bus_results_3ph(net, bus_pq): def write_voltage_dependend_load_results(net, p, q, b): - l = net["load"] + load_df = net["load"] _is_elements = net["_is_elements"] - if len(l) > 0: - load_is = _is_elements["load"] - scaling = l["scaling"].values - bus_lookup = net["_pd2ppc_lookups"]["bus"] - lidx = bus_lookup[l["bus"].values] + if load_df.empty: + return p, q, b + + load_is = _is_elements["load"] + scaling = load_df["scaling"].values + bus_lookup = net["_pd2ppc_lookups"]["bus"] + lidx = bus_lookup[load_df["bus"].values] - voltage_depend_loads = net["_options"]["voltage_depend_loads"] + voltage_depend_loads = net["_options"]["voltage_depend_loads"] - cz_p = l["const_z_p_percent"].values / 100. - ci_p = l["const_i_p_percent"].values / 100. - cp = 1. - (cz_p + ci_p) + cz_p = load_df["const_z_p_percent"].values / 100. + ci_p = load_df["const_i_p_percent"].values / 100. + cp = 1. - (cz_p + ci_p) - # constant power - pl = l["p_mw"].values * scaling * load_is * cp - net["res_load"]["p_mw"] = pl - p = np.hstack([p, pl]) + # constant power + pl = load_df["p_mw"].values * scaling * load_is * cp + net["res_load"]["p_mw"] = pl + p = np.hstack([p, pl]) - cz_q = l["const_z_q_percent"].values / 100. - ci_q = l["const_i_q_percent"].values / 100. - cq = 1. - (cz_q + ci_q) + cz_q = load_df["const_z_q_percent"].values / 100. + ci_q = load_df["const_i_q_percent"].values / 100. + cq = 1. - (cz_q + ci_q) - ql = l["q_mvar"].values * scaling * load_is * cq - net["res_load"]["q_mvar"] = ql - q = np.hstack([q, ql]) + ql = load_df["q_mvar"].values * scaling * load_is * cq + net["res_load"]["q_mvar"] = ql + q = np.hstack([q, ql]) - b = np.hstack([b, l["bus"].values]) + b = np.hstack([b, load_df["bus"].values]) - if voltage_depend_loads: - # constant impedance and constant current - vm_l = net["_ppc"]["bus"][lidx, 7] - volt_depend_p = ci_p * vm_l + cz_p * vm_l ** 2 - pl = l["p_mw"].values * scaling * load_is * volt_depend_p - net["res_load"]["p_mw"] += pl - p = np.hstack([p, pl]) + if voltage_depend_loads: + # constant impedance and constant current + vm_l = net["_ppc"]["bus"][lidx, 7] + volt_depend_p = ci_p * vm_l + cz_p * vm_l ** 2 + pl = load_df["p_mw"].values * scaling * load_is * volt_depend_p + net["res_load"]["p_mw"] += pl + p = np.hstack([p, pl]) - volt_depend_q = ci_q * vm_l + cz_q * vm_l ** 2 - ql = l["q_mvar"].values * scaling * load_is * volt_depend_q #* volt_depend - net["res_load"]["q_mvar"] += ql - q = np.hstack([q, ql]) + volt_depend_q = ci_q * vm_l + cz_q * vm_l ** 2 + ql = load_df["q_mvar"].values * scaling * load_is * volt_depend_q #* volt_depend + net["res_load"]["q_mvar"] += ql + q = np.hstack([q, ql]) - b = np.hstack([b, l["bus"].values]) - return p, q, b + b = np.hstack([b, load_df["bus"].values]) + return p, q, b def write_pq_results_to_element(net, ppc, element, suffix=None): @@ -427,7 +430,7 @@ def write_pq_results_to_element_3ph(net, element): def get_p_q_b(net, element, suffix=None): ac = net["_options"]["ac"] res_ = "res_" + element - if suffix != None: + if suffix is not None: res_ += "_%s" % suffix # bus values are needed for stacking @@ -439,7 +442,7 @@ def get_p_q_b(net, element, suffix=None): def get_p_q_b_3ph(net, element): ac = net["_options"]["ac"] - res_ = "res_" + element+"_3ph" + res_ = "res_" + element+ "_3ph" # bus values are needed for stacking b = net[element]["bus"].values diff --git a/pandapower/run.py b/pandapower/run.py index ce01403dfc..7b34f3c605 100644 --- a/pandapower/run.py +++ b/pandapower/run.py @@ -48,7 +48,7 @@ def set_user_pf_options(net, overwrite=False, **kwargs): 'max_iteration', 'v_debug', 'run_control', 'distributed_slack', 'lightsim2grid', 'tdpf', 'tdpf_delay_s', 'tdpf_update_r_theta'] - if overwrite or 'user_pf_options' not in net.keys(): + if overwrite or 'user_pf_options' not in net: net['user_pf_options'] = {} net.user_pf_options.update({key: val for key, val in kwargs.items() @@ -261,7 +261,7 @@ def runpp_pgm(net, algorithm="nr", max_iterations=20, error_tolerance_vm_pu=1e-8 - "lc" - Linear current approximation algorithm - "lin" - Linear approximation algorithm - error_tolerance_u_pu (float, 1e-8): error tolerance for voltage in p.u. + error_tolerance_vm_pu (float, 1e-8): error tolerance for voltage in p.u. max_iterations (int, 20): Maximum number of iterations for algorithms. No effect on linear approximation algorithms. validate_input (bool, False): Validate input data to be used for power-flow in power-grid-model. It is diff --git a/pandapower/shortcircuit/currents.py b/pandapower/shortcircuit/currents.py index bfe427d4f6..90b1163e73 100644 --- a/pandapower/shortcircuit/currents.py +++ b/pandapower/shortcircuit/currents.py @@ -144,7 +144,7 @@ def _current_source_current(net, ppci, bus_idx): # _is_elements_final exists for some reason, and weirdly it can be different than _is_elements. # it is not documented anywhere why it exists and I don't have any time to find out, but this here fixes the problem. - if np.all(net.sgen.current_source.values): + if "current_source" not in net.sgen.columns or np.all(net.sgen.current_source.values): sgen = net.sgen[net._is_elements_final["sgen"]] else: sgen = net.sgen[net._is_elements_final["sgen"] & net.sgen.current_source] diff --git a/pandapower/shortcircuit/ppc_conversion.py b/pandapower/shortcircuit/ppc_conversion.py index 3b2cb5026d..8204921473 100644 --- a/pandapower/shortcircuit/ppc_conversion.py +++ b/pandapower/shortcircuit/ppc_conversion.py @@ -233,6 +233,12 @@ def _add_gen_sc_z_kg_ks(net, ppc): if np.any(np.isnan(p_t)): # TODO: Check if tap is always on HV side + if "tap_step_percent" not in ps_trafo: + ps_trafo["tap_step_percent"] = float('nan') + if "tap_max" not in ps_trafo: + ps_trafo["tap_max"] = float('nan') + if "tap_neutral" not in ps_trafo: + ps_trafo["tap_neutral"] = float('nan') p_t[np.isnan(p_t)] =\ -(ps_trafo["tap_step_percent"].values * (ps_trafo["tap_max"].values - ps_trafo["tap_neutral"].values))[np.isnan(p_t)] / 100 @@ -321,5 +327,5 @@ def _create_k_updated_ppci(net, ppci_orig, ppci_bus, zero_sequence=False): return non_ps_gen_bus, ppci, bus_ppci -# TODO Roman: correction factor for 1ph cases +# TODO: correction factor for 1ph cases diff --git a/pandapower/std_types.py b/pandapower/std_types.py index 4d543f7b9b..8e0d765975 100644 --- a/pandapower/std_types.py +++ b/pandapower/std_types.py @@ -65,11 +65,11 @@ def create_std_type( data: dictionary of standard type parameters name: name of the standard type as string element: - + - "line" - "trafo" - "trafo3w" - + overwrite: whether overwrite existing standard type is allowed check_required: check if required standard type parameters are present @@ -115,12 +115,12 @@ def create_std_types(net, data, element="line", overwrite=True, check_required=T net: The pandapower network data: dictionary of standard type parameter sets element: - + - "line" - "line_dc" - "trafo" - "trafo3w" - + overwrite: whether overwriteing existing standard type is allowed check_required: check if required standard type parameters are present @@ -217,7 +217,8 @@ def rename_std_type(net, old_name, new_name, element="line"): if new_name in library: raise UserWarning(f"{element} standard type '{new_name}' already exists.") library[new_name] = library.pop(old_name) - net[element].loc[net[element].std_type == old_name, "std_type"] = new_name + if "std_type" in net[element].columns: + net[element].loc[net[element].std_type == old_name, "std_type"] = new_name def available_std_types(net, element="line"): @@ -263,13 +264,14 @@ def parameter_from_std_type(net, parameter, element="line", fill=None): """ if parameter not in net[element]: net[element][parameter] = fill - for typ in net[element].std_type.unique(): - if pd.isnull(typ) or not std_type_exists(net, typ, element): - continue - typedata = load_std_type(net, name=typ, element=element) - if parameter in typedata: - util = net[element].loc[net[element].std_type == typ].index - net[element].loc[util, parameter] = typedata[parameter] + if "std_type" in net[element].columns: + for typ in net[element].std_type.unique(): + if pd.isnull(typ) or not std_type_exists(net, typ, element): + continue + typedata = load_std_type(net, name=typ, element=element) + if parameter in typedata: + util = net[element].loc[net[element].std_type == typ].index + net[element].loc[util, parameter] = typedata[parameter] if fill is not None: net[element].loc[pd.isnull(net[element][parameter]).values, parameter] = fill @@ -290,6 +292,9 @@ def change_std_type(net, eid, name, element="line"): for column in table.columns: if column in type_param: table.at[eid, column] = type_param[column] + # add column if not present and init it to pd.NA + if "std_type" not in table.columns: + table["std_type"] = pd.Series(data=pd.NA, dtype=pd.StringDtype()) table.at[eid, "std_type"] = name @@ -389,7 +394,7 @@ def add_zero_impedance_parameters(net): def add_temperature_coefficient(net, fill=None): """ Adds alpha parameter for calculations of line temperature - + Parameters: net: pandapower network fill: fill value for when the parameter in std_type is missing, e.g. 4.03e-3 for aluminum or 3.93e-3 for copper diff --git a/pandapower/test/api/test_auxiliary.py b/pandapower/test/api/test_auxiliary.py index c14f11316f..addee57942 100644 --- a/pandapower/test/api/test_auxiliary.py +++ b/pandapower/test/api/test_auxiliary.py @@ -9,26 +9,19 @@ import geojson import numpy as np import pandas as pd - -from pandapower.control import SplineCharacteristic, Characteristic -from pandapower.control.util.characteristic import LogSplineCharacteristic from math import isclose -try: - import geopandas as gpd - import shapely.geometry - GEOPANDAS_INSTALLED = True -except ImportError: - GEOPANDAS_INSTALLED = False - -from pandapower.toolbox.element_selection import get_gc_objects_dict +from pandapower.control.util.characteristic import LogSplineCharacteristic +from pandapower.toolbox.element_selection import get_gc_objects_dict from pandapower.file_io import from_json_string, to_json, create_empty_network from pandapower.create import create_bus, create_lines, create_line, create_buses, create_shunt +from pandapower.create._utils import add_column_to_df from pandapower.auxiliary import get_indices, pandapowerNet from pandapower.networks import example_simple, example_multivoltage, mv_oberrhein from pandapower.timeseries import DFData from pandapower.control import ( SplineCharacteristic, + Characteristic, ContinuousTapControl, ConstControl, create_trafo_characteristic_object, @@ -36,6 +29,13 @@ from pandapower.control.util.auxiliary import (create_shunt_characteristic_object, _create_trafo_characteristics, create_q_capability_characteristics_object, get_min_max_q_mvar_from_characteristics_object) +try: + import geopandas as gpd + import shapely.geometry + GEOPANDAS_INSTALLED = True +except ImportError: + GEOPANDAS_INSTALLED = False + class MemoryLeakDemo: """ @@ -222,6 +222,8 @@ def test_create_trafo_characteristics(): 'angle_deg': [0, 0, 0, 0, 0], 'vk_percent': [2, 3, 4, 5, 6], 'vkr_percent': [1.323, 1.324, 1.325, 1.326, 1.327], 'vk_hv_percent': np.nan, 'vkr_hv_percent': np.nan, 'vk_mv_percent': np.nan, 'vkr_mv_percent': np.nan, 'vk_lv_percent': np.nan, 'vkr_lv_percent': np.nan}) + add_column_to_df(net, "trafo", "id_characteristic_table") + add_column_to_df(net, "trafo", 'tap_dependency_table') net.trafo.at[1, 'id_characteristic_table'] = 0 net.trafo.at[0, 'tap_dependency_table'] = False net.trafo.at[1, 'tap_dependency_table'] = True @@ -279,6 +281,8 @@ def test_create_trafo_characteristics(): 'vkr_mv_percent': [1.323, 1.325, 1.329, 1.331, 1.339], 'vk_lv_percent': [8.1, 9.5, 10, 11.1, 12.9], 'vkr_lv_percent': [1.323, 1.325, 1.329, 1.331, 1.339]}) net["trafo_characteristic_table"] = pd.concat([net["trafo_characteristic_table"], new_rows], ignore_index=True) + add_column_to_df(net, "trafo3w", 'id_characteristic_table') + add_column_to_df(net, "trafo3w", 'tap_dependency_table') net.trafo3w.at[0, 'id_characteristic_table'] = 2 net.trafo3w.at[0, 'tap_dependency_table'] = True # create spline characteristics again including a 3-winding transformer @@ -372,6 +376,7 @@ def test_creation_of_q_capability_characteristics(): net["q_capability_curve_table"] = pd.DataFrame( {'id_q_capability_curve': [0, 0, 0, 0, 0], 'p_mw': [0.0, 50.0, 100.0, 125.0, 125.0], 'q_min_mvar': [-100.0, -75.0, -50.0, -25.0, -10], 'q_max_mvar': [150.0, 125.0, 75, 50.0, 10.0]}) + add_column_to_df(net, "gen", "id_q_capability_characteristic") net.gen.at[0, "id_q_capability_characteristic"] = 0 net.gen['curve_style'] = "straightLineYValues" @@ -433,8 +438,9 @@ def test_get_min_max_q_capability(): 'p_mw': p_mw, 'q_min_mvar': q_min_mvar, 'q_max_mvar': q_max_mvar}) - + add_column_to_df(net, 'sgen', "id_q_capability_characteristic",) net.sgen.loc[sgen_indices_with_char, 'id_q_capability_characteristic'] = [0, 1] + add_column_to_df(net, 'sgen', "reactive_capability_curve",) net.sgen.loc[sgen_indices_with_char, 'curve_style'] = "straightLineYValues" create_q_capability_characteristics_object(net) diff --git a/pandapower/test/api/test_create.py b/pandapower/test/api/test_create.py index 11c43e1f4f..4467640d7d 100644 --- a/pandapower/test/api/test_create.py +++ b/pandapower/test/api/test_create.py @@ -5,10 +5,13 @@ from copy import deepcopy +from functools import partial import geojson +import math import numpy as np from numpy import nan import pandas as pd +import pandera as pa import pytest from pandapower.create import ( @@ -23,12 +26,8 @@ ) from pandapower.run import runpp from pandapower.std_types import create_std_type -from pandapower.toolbox.comparison import nets_equal, dataframes_equal - -pd.set_option("display.max_rows", 500) -pd.set_option("display.max_columns", 500) -pd.set_option("display.width", 1000) - +from pandapower.toolbox import nets_equal, dataframes_equal +from pandapower.network_schema.tools.validation.network_validation import validate_network def test_convenience_create_functions(): net = create_empty_network() @@ -102,9 +101,14 @@ def test_convenience_create_functions(): i0_percent=1, test_kwargs="dummy_string", ) + + validate_network(net) + create_load(net, b3, 0.1) assert net.trafo.at[tid, "df"] == 1 runpp(net) + validate_network(net) + tr_l = net.res_trafo.at[tid, "loading_percent"] net.trafo.at[tid, "df"] = 2 runpp(net) @@ -115,10 +119,11 @@ def test_convenience_create_functions(): runpp(net) assert net.trafo.test_kwargs.at[tid] == "dummy_string" + with pytest.raises(pa.errors.SchemaError): + validate_network(net) -def test_nonexistent_bus(): - from functools import partial +def test_nonexistent_bus(): net = create_empty_network() create_functions = [ partial(create_load, net=net, p_mw=0, q_mvar=0, bus=0, index=0), @@ -241,9 +246,10 @@ def test_nonexistent_bus(): ): # exception is raised because index already exists func() + validate_network(net) + def test_tap_changer_type_default(): - expected_default = math.nan # comment: wanted to implement "None" as default, but some test rely on that some function converts NaN to ratio tap changer. net = create_empty_network() create_bus(net, 110) create_bus(net, 20) @@ -253,8 +259,10 @@ def test_tap_changer_type_default(): create_std_type(net, data, "without_tap_shifter_info", "trafo") create_transformer_from_parameters(net, 0, 1, 25e3, 110, 20, 0.4, 12, 20, 0.07) create_transformer(net, 0, 1, "without_tap_shifter_info") - #assert (net.trafo.tap_changer_type == expected_default).all() # comparison with NaN is always false. revert back to this - assert (net.trafo.tap_changer_type.isna()).all() + if 'tap_changer_type' in net.trafo.columns: + assert (net.trafo.tap_changer_type.isna()).all() + + validate_network(net) def test_create_line_conductance(): @@ -279,6 +287,8 @@ def test_create_line_conductance(): assert net.line.g_us_per_km.at[l] == 1 assert net.line.test_kwargs.at[l] == "dummy_string" + validate_network(net) + def test_create_buses(): net = create_empty_network() @@ -298,13 +308,15 @@ def test_create_buses(): for i, ind in enumerate(b3): assert net.bus.at[ind, "geo"] == geojson.dumps(geojson.Point(geodata[i]), sort_keys=True) + validate_network(net) + def test_create_lines(): # standard net = create_empty_network() b1 = create_bus(net, 10) b2 = create_bus(net, 10) - l = create_lines( + create_lines( net, [b1, b1], [b2, b2], @@ -317,10 +329,12 @@ def test_create_lines(): assert len(set(net.line.r_ohm_per_km)) == 1 assert all(net.line.test_kwargs == "dummy_string") + validate_network(net) + net = create_empty_network() b1 = create_bus(net, 10) b2 = create_bus(net, 10) - l = create_lines( + create_lines( net, [b1, b1], [b2, b2], @@ -331,6 +345,8 @@ def test_create_lines(): assert sum(net.line.std_type == "48-AL1/8-ST1A 10.0") == 1 assert sum(net.line.std_type == "NA2XS2Y 1x240 RM/25 6/10 kV") == 1 + validate_network(net) + # with geodata net = create_empty_network() b1 = create_bus(net, 10) @@ -348,6 +364,8 @@ def test_create_lines(): assert net.line.at[l[0], "geo"] == geojson.dumps(geojson.LineString([(1, 1), (2, 2), (3, 3)]), sort_keys=True) assert net.line.at[l[1], "geo"] == geojson.dumps(geojson.LineString([(1, 1), (1, 2)]), sort_keys=True) + validate_network(net) + # setting params as single value net = create_empty_network() b1 = create_bus(net, 10) @@ -381,6 +399,8 @@ def test_create_lines(): assert net.line.at[l[0], "parallel"] == 1 assert net.line.at[l[1], "parallel"] == 1 + validate_network(net) + # setting params as array net = create_empty_network() b1 = create_bus(net, 10) @@ -414,6 +434,8 @@ def test_create_lines(): assert net.line.at[l[0], "parallel"] == 2 assert net.line.at[l[1], "parallel"] == 1 + validate_network(net) + def test_create_lines_from_parameters(): # standard @@ -459,6 +481,8 @@ def test_create_lines_from_parameters(): assert net.line.at[l[0], "geo"] == geojson.dumps(geojson.LineString([(1, 1), (2, 2), (3, 3)]), sort_keys=True) assert net.line.at[l[1], "geo"] == geojson.dumps(geojson.LineString([(1, 1), (1, 2)]), sort_keys=True) + validate_network(net) + # setting params as single value net = create_empty_network() b1 = create_bus(net, 10) @@ -493,7 +517,7 @@ def test_create_lines_from_parameters(): assert all(net.line["r0_ohm_per_km"].values == 0.1) assert all(net.line["g0_us_per_km"].values == 0) assert all(net.line["c0_nf_per_km"].values == 0) - assert net.line.in_service.dtype == bool + assert net.line.in_service.dtype == np.dtype(bool) assert not net.line.at[l[0], "in_service"] # is actually assert not net.line.at[l[1], "in_service"] # is actually assert net.line.at[l[0], "geo"] == geojson.dumps(geojson.LineString([(10, 10), (20, 20)]), sort_keys=True) @@ -505,6 +529,8 @@ def test_create_lines_from_parameters(): assert all(net.line["alpha"].values == 0.04) assert all(net.line.test_kwargs == "dummy_string") + validate_network(net) + # setting params as array net = create_empty_network() b1 = create_bus(net, 10) @@ -545,7 +571,7 @@ def test_create_lines_from_parameters(): assert net.line.at[l[1], "x0_ohm_per_km"] == 0.25 assert all(net.line["g0_us_per_km"].values == 0) assert all(net.line["c0_nf_per_km"].values == 0) - assert net.line.in_service.dtype == bool + assert net.line.in_service.dtype == np.dtype(bool) assert net.line.at[l[0], "in_service"] assert not net.line.at[l[1], "in_service"] assert net.line.at[l[0], "name"] == "test1" @@ -559,6 +585,8 @@ def test_create_lines_from_parameters(): assert net.line.at[l[0], "max_i_ka"] == 100 assert net.line.at[l[1], "max_i_ka"] == 200 + validate_network(net) + def test_create_lines_raise_errorexcept(): # standard @@ -613,6 +641,8 @@ def test_create_lines_raise_errorexcept(): max_i_ka=[100, 100], ) + validate_network(net) + def test_create_lines_optional_columns(): # @@ -643,6 +673,8 @@ def test_create_lines_optional_columns(): # create_lines_from_parameters(net, [3, 4], [4, 3], [10, 11], 1, 1, 1, 100, max_loading_percent=[v, v]) assert "max_loading_percent" not in net.line.columns + validate_network(net) + def test_create_line_alpha_temperature(): net = create_empty_network() @@ -675,6 +707,8 @@ def test_create_line_alpha_temperature(): create_line_from_parameters(net, 3, 4, 10, 1, 1, 1, 100, alpha=4.03e-3, wind_speed_m_per_s=np.nan) assert "wind_speed_m_per_s" not in net.line.columns + validate_network(net) + def test_create_transformers_from_parameters(): # standard @@ -717,6 +751,8 @@ def test_create_transformers_from_parameters(): assert len(net.trafo.df) == 2 assert len(net.trafo.foo) == 2 + validate_network(net) + # setting params as single value net = create_empty_network() b1 = create_bus(net, 15) @@ -736,7 +772,7 @@ def test_create_transformers_from_parameters(): vkr0_percent=1.7, mag0_rx=0.4, mag0_percent=30, - tap_neutral=0.0, + # tap_neutral=0.0, FIXME add tap_side and tap_pos or remove vector_group="Dyn", si0_hv_partial=0.1, max_loading_percent=80, @@ -755,13 +791,15 @@ def test_create_transformers_from_parameters(): assert all(net.trafo.vk0_percent == 0.4) assert all(net.trafo.mag0_rx == 0.4) assert all(net.trafo.mag0_percent == 30) - assert all(net.trafo.tap_neutral == 0.0) - assert all(net.trafo.tap_pos == 0.0) + # assert all(net.trafo.tap_neutral == 0.0) FIXME either add tap_side or remove this + # assert all(net.trafo.tap_pos == 0.0) assert all(net.trafo.vector_group.values == "Dyn") assert all(net.trafo.max_loading_percent == 80.0) assert all(net.trafo.si0_hv_partial == 0.1) assert all(net.trafo.test_kwargs == "dummy_string") + validate_network(net) + # setting params as array net = create_empty_network() b1 = create_bus(net, 10) @@ -780,8 +818,8 @@ def test_create_transformers_from_parameters(): vk0_percent=[0.4, 0.4], mag0_rx=[0.4, 0.4], mag0_percent=[30, 30], - tap_neutral=[0.0, 1.0], - tap_pos=[-1, 4], + # tap_neutral=[0.0, 1.0], FIXME add tap_side or remove + # tap_pos=[-1, 4], test_kwargs=["dummy_string", "dummy_string"], ) @@ -799,10 +837,12 @@ def test_create_transformers_from_parameters(): assert all(net.trafo.mag0_rx == 0.4) assert all(net.trafo.mag0_percent == 30) assert all(net.trafo.test_kwargs == "dummy_string") - assert net.trafo.tap_neutral.at[t[0]] == 0 - assert net.trafo.tap_neutral.at[t[1]] == 1 - assert net.trafo.tap_pos.at[t[0]] == -1 - assert net.trafo.tap_pos.at[t[1]] == 4 + # assert net.trafo.tap_neutral.at[t[0]] == 0 FIXME add tap_side or remove + # assert net.trafo.tap_neutral.at[t[1]] == 1 + # assert net.trafo.tap_pos.at[t[0]] == -1 + # assert net.trafo.tap_pos.at[t[1]] == 4 + + validate_network(net) def test_create_transformers_raise_errorexcept(): @@ -838,6 +878,11 @@ def test_create_transformers_raise_errorexcept(): i0_percent=0.3, index=[2, 1], ) + validate_network(net) + + + +def test_create_transformer_raises_errorexcept1(): net = create_empty_network() b1 = create_bus(net, 10) b2 = create_bus(net, 10) @@ -887,6 +932,8 @@ def test_create_transformers_raise_errorexcept(): foo=2, ) + validate_network(net) + def test_trafo_2_tap_changers(): net = create_empty_network() @@ -902,7 +949,7 @@ def test_trafo_2_tap_changers(): "tap2_step_degree": 0, "tap2_changer_type": "Ratio"} - for c in tap2_data.keys(): + for c in tap2_data: assert c not in net.trafo.columns std_type = load_std_type(net, "40 MVA 110/20 kV", "trafo") @@ -911,10 +958,12 @@ def test_trafo_2_tap_changers(): t = create_transformer(net, b1, b2, "test_trafo_type") - for c in tap2_data.keys(): + for c in tap2_data: assert c in net.trafo.columns assert net.trafo.at[t, c] == tap2_data[c] + validate_network(net) + def test_trafo_2_tap_changers_parameters(): net = create_empty_network() @@ -932,15 +981,17 @@ def test_trafo_2_tap_changers_parameters(): create_transformer_from_parameters(net, b1, b2, **std_type) - for c in tap2_data.keys(): + for c in tap2_data: assert c not in net.trafo.columns t = create_transformer_from_parameters(net, b1, b2, **std_type, **tap2_data) - for c in tap2_data.keys(): + for c in tap2_data: assert c in net.trafo.columns assert net.trafo.at[t, c] == tap2_data[c] + validate_network(net) + def test_trafos_2_tap_changers_parameters(): net = create_empty_network() @@ -960,15 +1011,17 @@ def test_trafos_2_tap_changers_parameters(): create_transformers_from_parameters(net, [b1, b1], [b2, b2], **std_type_p) - for c in tap2_data.keys(): + for c in tap2_data: assert c not in net.trafo.columns t = create_transformer_from_parameters(net, b1, b2, **std_type, **tap2_data) - for c in tap2_data.keys(): + for c in tap2_data: assert c in net.trafo.columns assert net.trafo.at[t, c] == tap2_data[c] + validate_network(net) + def test_create_transformers(): net = create_empty_network() @@ -984,35 +1037,37 @@ def test_create_transformers(): test_kwargs="TestKW" ) res_df = pd.DataFrame({ - 'name': ['trafo1', 'trafo2'], - 'std_type': ['0.4 MVA 10/0.4 kV', '0.4 MVA 10/0.4 kV'], - 'hv_bus': pd.Series([0, 0], dtype=np.uint32), - 'lv_bus': pd.Series([1, 2], dtype=np.uint32), - 'sn_mva': [0.4, 0.4], - 'vn_hv_kv': [10.0, 10.0], - 'vn_lv_kv': [0.4, 0.4], - 'vk_percent': [4.0, 4.0], - 'vkr_percent': [1.325, 1.325], - 'pfe_kw': [0.95, 0.95], - 'i0_percent': [0.2375, 0.2375], - 'shift_degree': [0.0, 0.0], - 'tap_side': ['', ''], - 'tap_neutral': [nan, nan], - 'tap_min': [nan, nan], - 'tap_max': [nan, nan], - 'tap_step_percent': [nan, nan], - 'tap_step_degree': [nan, nan], - 'tap_pos': [nan, nan], - 'tap_changer_type': ['', ''], - 'id_characteristic_table': pd.Series([pd.NA, pd.NA], dtype=pd.Int64Dtype), - 'tap_dependency_table': [False, False], - 'parallel': pd.Series([1, 1], dtype=np.uint32), - 'df': [1.0, 1.0], - 'in_service': [True, True], - 'oltc': [False, False], + 'name': pd.Series(['trafo1', 'trafo2'], dtype=pd.StringDtype), + 'std_type': pd.Series(['0.4 MVA 10/0.4 kV', '0.4 MVA 10/0.4 kV'], dtype=pd.StringDtype), + 'hv_bus': pd.Series([0, 0], dtype=np.int64), + 'lv_bus': pd.Series([1, 2], dtype=np.int64), + 'sn_mva': pd.Series([0.4, 0.4], dtype=np.float64), + 'vn_hv_kv': pd.Series([10.0, 10.0], dtype=np.float64), + 'vn_lv_kv': pd.Series([0.4, 0.4], dtype=np.float64), + 'vk_percent': pd.Series([4.0, 4.0], dtype=np.float64), + 'vkr_percent': pd.Series([1.325, 1.325], dtype=np.float64), + 'pfe_kw': pd.Series([0.95, 0.95], dtype=np.float64), + 'i0_percent': pd.Series([0.2375, 0.2375], dtype=np.float64), + 'shift_degree': pd.Series([0.0, 0.0], dtype=np.float64), + # 'tap_side': ['', ''], + # 'tap_neutral': [nan, nan], + # 'tap_min': [nan, nan], + # 'tap_max': [nan, nan], + # 'tap_step_percent': [nan, nan], + # 'tap_step_degree': [nan, nan], + # 'tap_pos': [nan, nan], + # 'tap_changer_type': [pd.NA, pd.NA], + # 'id_characteristic_table': pd.Series([pd.NA, pd.NA], dtype=pd.Int64Dtype), + # 'tap_dependency_table': [False, False], + 'parallel': pd.Series([1, 1], dtype=np.int64), + 'df': pd.Series([1.0, 1.0], dtype=np.float64), + 'in_service': pd.Series([True, True], dtype=bool), + # 'oltc': [False, False], 'test_kwargs': ['TestKW', 'TestKW'], - 'vector_group': ['Dyn5', 'Dyn5'], + 'vector_group': pd.Series(['Dyn5', 'Dyn5'], dtype=pd.StringDtype), }) + for colum in res_df: + assert net.trafo[colum].equals(res_df[colum]) assert dataframes_equal(net.trafo, res_df) def test_create_transformers_for_single(): @@ -1029,37 +1084,39 @@ def test_create_transformers_for_single(): sn_mva=.4 ) res_df = pd.DataFrame({ - 'name': ['trafo1'], - 'std_type': ['0.4 MVA 10/0.4 kV'], - 'hv_bus': pd.Series([0], dtype=np.uint32), - 'lv_bus': pd.Series([1], dtype=np.uint32), - 'sn_mva': [0.4], - 'vn_hv_kv': [10.0], - 'vn_lv_kv': [0.4], - 'vk_percent': [4.0], - 'vkr_percent': [1.325], - 'pfe_kw': [0.95], - 'i0_percent': [0.2375], - 'shift_degree': [0.0], - 'tap_side': [''], - 'tap_neutral': [nan], - 'tap_min': [nan], - 'tap_max': [nan], - 'tap_step_percent': [nan], - 'tap_step_degree': [nan], - 'tap_pos': [nan], - 'tap_changer_type': [''], - 'id_characteristic_table': pd.Series([pd.NA], dtype=pd.Int64Dtype), - 'tap_dependency_table': [False], - 'parallel': pd.Series([1], dtype=np.uint32), - 'df': [1.0], - 'in_service': [True], - 'oltc': [False], + 'name': pd.Series(['trafo1'], dtype=pd.StringDtype), + 'std_type': pd.Series(['0.4 MVA 10/0.4 kV'], dtype=pd.StringDtype), + 'hv_bus': pd.Series([0], dtype=np.int64), + 'lv_bus': pd.Series([1], dtype=np.int64), + 'sn_mva': pd.Series([0.4], dtype=np.float64), + 'vn_hv_kv': pd.Series([10.0], dtype=np.float64), + 'vn_lv_kv': pd.Series([0.4], dtype=np.float64), + 'vk_percent': pd.Series([4.0], dtype=np.float64), + 'vkr_percent': pd.Series([1.325], dtype=np.float64), + 'pfe_kw': pd.Series([0.95], dtype=np.float64), + 'i0_percent': pd.Series([0.2375], dtype=np.float64), + 'shift_degree': pd.Series([0.0], dtype=np.float64), + # 'tap_side': [''], + # 'tap_neutral': [nan], + # 'tap_min': [nan], + # 'tap_max': [nan], + # 'tap_step_percent': [nan], + # 'tap_step_degree': [nan], + # 'tap_pos': [nan], + # 'tap_changer_type': [''], + # 'id_characteristic_table': pd.Series([pd.NA], dtype=pd.Int64Dtype), + # 'tap_dependency_table': [False], + 'parallel': pd.Series([1], dtype=np.int64), + 'df': pd.Series([1.0], dtype=np.float64), + 'in_service': pd.Series([True], dtype=bool), + # 'oltc': [False], 'test_kwargs': ['TestKW'], - 'vector_group': ['Dyn5'], + 'vector_group': pd.Series(['Dyn5'], dtype=pd.StringDtype), }) assert dataframes_equal(net.trafo, res_df) + validate_network(net) + def test_create_transformers3w(): net = create_empty_network() @@ -1079,42 +1136,46 @@ def test_create_transformers3w(): index=[5, 6], ) res_df = pd.DataFrame({ - 'name': ['t3w-1', 't3w-2'], - 'std_type': ['63/25/38 MVA 110/20/10 kV', '63/25/38 MVA 110/20/10 kV'], - 'hv_bus': pd.Series([0, 0], dtype=np.uint32), - 'mv_bus': pd.Series([1, 2], dtype=np.uint32), - 'lv_bus': pd.Series([3, 4], dtype=np.uint32), - 'sn_hv_mva': [63.0, 63.0], - 'sn_mv_mva': [25.0, 25.0], - 'sn_lv_mva': [38.0, 38.0], - 'vn_hv_kv': [110.0, 110.0], - 'vn_mv_kv': [20.0, 20.0], - 'vn_lv_kv': [10.0, 10.0], - 'vk_hv_percent': [10.4, 10.4], - 'vk_mv_percent': [10.4, 10.4], - 'vk_lv_percent': [10.4, 10.4], - 'vkr_hv_percent': [0.28, 0.28], - 'vkr_mv_percent': [0.32, 0.32], - 'vkr_lv_percent': [0.35, 0.35], - 'pfe_kw': [35.0, 35.0], - 'i0_percent': [0.89, 0.89], - 'shift_mv_degree': [0.0, 0.0], - 'shift_lv_degree': [0.0, 0.0], - 'tap_side': ['hv', 'hv'], - 'tap_neutral': [0.0, 0.0], - 'tap_min': [-10.0, -10.0], - 'tap_max': [10.0, 10.0], - 'tap_step_percent': [1.2, 1.2], - 'tap_step_degree': [nan, nan], - 'tap_pos': [0.0, 0.0], - 'tap_at_star_point': [False, False], - 'tap_changer_type': ['Ratio', 'Ratio'], - 'id_characteristic_table': pd.Series([pd.NA, pd.NA], dtype=pd.Int64Dtype), - 'tap_dependency_table': [False, False], - 'in_service': [True, False] + 'name': pd.Series(['t3w-1', 't3w-2'], dtype=pd.StringDtype), + 'std_type': pd.Series(['63/25/38 MVA 110/20/10 kV', '63/25/38 MVA 110/20/10 kV'], dtype=pd.StringDtype), + 'hv_bus': pd.Series([0, 0], dtype=np.int64), + 'mv_bus': pd.Series([1, 2], dtype=np.int64), + 'lv_bus': pd.Series([3, 4], dtype=np.int64), + 'sn_hv_mva': pd.Series([63.0, 63.0], dtype=np.float64), + 'sn_mv_mva': pd.Series([25.0, 25.0], dtype=np.float64), + 'sn_lv_mva': pd.Series([38.0, 38.0], dtype=np.float64), + 'vn_hv_kv': pd.Series([110.0, 110.0], dtype=np.float64), + 'vn_mv_kv': pd.Series([20.0, 20.0], dtype=np.float64), + 'vn_lv_kv': pd.Series([10.0, 10.0], dtype=np.float64), + 'vk_hv_percent': pd.Series([10.4, 10.4], dtype=np.float64), + 'vk_mv_percent': pd.Series([10.4, 10.4], dtype=np.float64), + 'vk_lv_percent': pd.Series([10.4, 10.4], dtype=np.float64), + 'vkr_hv_percent': pd.Series([0.28, 0.28], dtype=np.float64), + 'vkr_mv_percent': pd.Series([0.32, 0.32], dtype=np.float64), + 'vkr_lv_percent': pd.Series([0.35, 0.35], dtype=np.float64), + 'pfe_kw': pd.Series([35.0, 35.0], dtype=np.float64), + 'i0_percent': pd.Series([0.89, 0.89], dtype=np.float64), + 'shift_mv_degree': pd.Series([0.0, 0.0], dtype=np.float64), + 'shift_lv_degree': pd.Series([0.0, 0.0], dtype=np.float64), + 'tap_side': pd.Series(['hv', 'hv'], dtype=pd.StringDtype), + 'tap_neutral': pd.Series([0.0, 0.0], dtype=np.float64), + 'tap_min': pd.Series([-10.0, -10.0], dtype=np.float64), + 'tap_max': pd.Series([10.0, 10.0], dtype=np.float64), + 'tap_step_percent': pd.Series([1.2, 1.2], dtype=np.float64), + 'tap_step_degree': pd.Series([0.0, 0.0], dtype=np.float64), + 'tap_pos': pd.Series([0.0, 0.0], dtype=np.float64), + 'tap_at_star_point': pd.Series([False, False], dtype=pd.BooleanDtype), + # 'tap_changer_type': ['Ratio', 'Ratio'], + # 'id_characteristic_table': pd.Series([pd.NA, pd.NA], dtype=pd.Int64Dtype), + # 'tap_dependency_table': [False, False], + 'in_service': pd.Series([True, False], dtype=bool) }).set_index(pd.Index([5, 6])) + for colum in res_df: + assert net.trafo3w[colum].equals(res_df[colum]) assert dataframes_equal(net.trafo3w, res_df) + validate_network(net) + def net_transformer3w_from_parameters(**kwargs): net = create_empty_network() b1 = create_bus(net, 15) @@ -1139,16 +1200,17 @@ def net_transformer3w_from_parameters(**kwargs): vkr_lv_percent=0.3, pfe_kw=0.2, i0_percent=0.3, - tap_neutral=0.0, + # tap_neutral=0.0, FIXME either remove this line or add tap_side and tap_pos mag0_rx=0.4, mag0_percent=30, **kwargs, ) return net, b1, b2, b3 + def test_create_transformers3w_from_parameters(): # setting params as single value - net, _, _ , _= net_transformer3w_from_parameters(test_kwargs="dummy_string") + net, *_ = net_transformer3w_from_parameters(test_kwargs="dummy_string") assert len(net.trafo3w) == 2 assert all(net.trafo3w.hv_bus == 0) assert all(net.trafo3w.lv_bus == 1) @@ -1169,8 +1231,8 @@ def test_create_transformers3w_from_parameters(): assert all(net.trafo3w.i0_percent == 0.3) assert all(net.trafo3w.mag0_rx == 0.4) assert all(net.trafo3w.mag0_percent == 30) - assert all(net.trafo3w.tap_neutral == 0.0) - assert all(net.trafo3w.tap_pos == 0.0) + #assert all(net.trafo3w.tap_neutral == 0.0) FIXME add tap_side or remove this + #assert all(net.trafo3w.tap_pos == 0.0) assert all(net.trafo3w.test_kwargs == "dummy_string") # setting params as array @@ -1197,8 +1259,8 @@ def test_create_transformers3w_from_parameters(): vkr_lv_percent=[0.3, 0.3], pfe_kw=[0.2, 0.1], i0_percent=[0.3, 0.2], - tap_neutral=[0.0, 5.0], - tap_pos=[1, 2], + # tap_neutral=[0.0, 5.0], FIXME either add tap_side or remove this + # tap_pos=[1, 2], in_service=[True, False], test_kwargs=["foo", "bar"], ) @@ -1220,11 +1282,13 @@ def test_create_transformers3w_from_parameters(): assert all(net.trafo3w.vkr_lv_percent == 0.3) assert all(net.trafo3w.pfe_kw == [0.2, 0.1]) assert all(net.trafo3w.i0_percent == [0.3, 0.2]) - assert all(net.trafo3w.tap_neutral == [0.0, 5.0]) - assert all(net.trafo3w.tap_pos == [1, 2]) + # assert all(net.trafo3w.tap_neutral == [0.0, 5.0]) FIXME either add tap_side or remove + # assert all(net.trafo3w.tap_pos == [1, 2]) assert all(net.trafo3w.in_service == [True, False]) assert all(net.trafo3w.test_kwargs == ["foo", "bar"]) + validate_network(net) + def test_create_transformers3w_raise_errorexcept(): # standard @@ -1252,11 +1316,12 @@ def test_create_transformers3w_raise_errorexcept(): vkr_lv_percent=0.3, pfe_kw=0.2, i0_percent=0.3, - tap_neutral=0.0, + # tap_neutral=0.0, FIXME either remove this line or add tap_side and tap_pos mag0_rx=0.4, mag0_percent=30, index=[2, 1], ) + validate_network(net) net = create_empty_network() b1 = create_bus(net, 15) b2 = create_bus(net, 0.4) @@ -1342,6 +1407,8 @@ def test_create_transformers3w_raise_errorexcept(): mag0_percent=30, ) + validate_network(net) + def test_create_switches(): net = create_empty_network() @@ -1378,6 +1445,8 @@ def test_create_switches(): assert net.switch.test_kwargs.at[1] == "aaa" assert net.switch.test_kwargs.at[2] == "aaa" + validate_network(net) + def test_create_switches_raise_errorexcept(): net = create_empty_network() @@ -1409,7 +1478,7 @@ def test_create_switches_raise_errorexcept(): vkr_lv_percent=0.3, pfe_kw=0.2, i0_percent=0.3, - tap_neutral=0.0, + #tap_neutral=0.0, # FIXME: either remove this or add tap_pos and tap_side ) sw = create_switch(net, bus=b1, element=l1, et="l", z_ohm=0.0) with pytest.raises( @@ -1500,6 +1569,8 @@ def test_create_switches_raise_errorexcept(): z_ohm=0.0, ) + validate_network(net) + def test_create_loads(): net = create_empty_network() @@ -1542,6 +1613,8 @@ def test_create_loads(): == ["dummy_string_1", "dummy_string_2", "dummy_string_3"] ) + validate_network(net) + def test_create_loads_raise_errorexcept(): net = create_empty_network() @@ -1591,6 +1664,9 @@ def test_create_loads_raise_errorexcept(): index=l, ) + validate_network(net) + + def test_const_percent_values_deprecated_handling(): # This test checks that passing const_z_percent and const_i_percent to create_load # sets all four percent columns and triggers the deprecation warning. @@ -1608,6 +1684,9 @@ def test_const_percent_values_deprecated_handling(): assert load_idx.const_i_p_percent == 22 assert load_idx.const_i_q_percent == 22 + validate_network(net) + + def test_create_storages(): net = create_empty_network() b1 = create_bus(net, 110) @@ -1656,14 +1735,14 @@ def test_create_storages(): assert all(net.storage.min_p_mw.values == [0, 0.1, 0]) assert all(net.storage.max_q_mvar.values == 0.2) assert all(net.storage.min_q_mvar.values == [0, 0.1, 0]) - assert all( - net.storage.test_kwargs.values - == ["dummy_string_1", "dummy_string_2", "dummy_string_3"] - ) + assert all(net.storage.test_kwargs.values == ["dummy_string_1", "dummy_string_2", "dummy_string_3"]) for col in ["name", "type"]: - net.storage.loc[net.storage[col].isnull(), col] = "" + if col in net.storage.columns: + net.storage.loc[net.storage[col].isnull(), col] = "" #TODO: why is this here ? assert nets_equal(net, net_bulk) + validate_network(net) + def test_create_wards(): net = create_empty_network() @@ -1696,13 +1775,16 @@ def test_create_wards(): assert net.ward.qz_mvar.at[1] == 7 assert net.ward.qz_mvar.at[2] == 11 assert net.ward.name.at[0] == "asd" - assert net.ward.name.at[1] == None + # assert net.ward.name.at[1] == None + assert pd.isna(net.ward.name.at[1]) #TODO: recheck, if would also be ok assert net.ward.name.at[2] == "123" assert net.ward.in_service.at[0] assert not net.ward.in_service.at[1] assert not net.ward.in_service.at[2] assert nets_equal(net, net_bulk) + validate_network(net) + def test_create_sgens(): net = create_empty_network() @@ -1757,9 +1839,6 @@ def test_create_sgens(): def test_create_sgen_controllable(): net = create_empty_network() - # drop controllable column (it is created by network schema but is not required by pandera) - # TODO remove this step with pandera merged fully - del net.sgen['controllable'] b1 = create_bus(net, 110) s1 = create_sgen(net, b1, 50) @@ -1773,9 +1852,6 @@ def test_create_sgen_controllable(): def test_create_sgens_controllable(): net = create_empty_network() - # drop controllable column (it is created by network schema but is not required by pandera) - # TODO remove this step with pandera merged fully - del net.sgen['controllable'] b1 = create_bus(net, 110) s1 = create_sgens(net, [b1], 50)[0] @@ -1786,6 +1862,8 @@ def test_create_sgens_controllable(): assert not net.sgen.loc[s1, 'controllable'] assert net.sgen.loc[s2, 'controllable'] + validate_network(net) + def test_create_sgens_raise_errorexcept(): net = create_empty_network() @@ -1844,6 +1922,8 @@ def test_create_sgens_raise_errorexcept(): index=sg, ) + validate_network(net) + def test_create_gens(): net = create_empty_network() @@ -1897,37 +1977,33 @@ def test_create_gens(): assert all(net.gen.curve_style == "straightLineYValues") assert all(net.gen.reactive_capability_curve == [False, False, False]) + validate_network(net) + def test_create_gen_controllable(): net = create_empty_network() - # drop controllable column (it is created by network schema but is not required by pandera) - # TODO remove this step with pandera merged fully - del net.gen['controllable'] - + b1 = create_bus(net, 110) s1 = create_gen(net, b1, 50) # controllable column should not exist assert 'controllable' not in net.gen.columns - s2 = create_gen(net, b1, 50, controllable=False) - # controllable should be created with default value True - assert net.gen.loc[s1, 'controllable'] - assert not net.gen.loc[s2, 'controllable'] + s2 = create_gen(net, b1, 50, controllable=True) + # controllable should be created with default value False + assert not net.gen.loc[s1, 'controllable'] + assert net.gen.loc[s2, 'controllable'] def test_create_gens_controllable(): net = create_empty_network() - # drop controllable column (it is created by network schema but is not required by pandera) - # TODO remove this step with pandera merged fully - del net.gen['controllable'] - + b1 = create_bus(net, 110) s1 = create_gens(net, [b1], 50)[0] # controllable column should not exist assert 'controllable' not in net.gen.columns - s2 = create_gens(net, [b1], 50, controllable=False)[0] - # controllable should be created with default value True - assert net.gen.loc[s1, 'controllable'] - assert not net.gen.loc[s2, 'controllable'] + s2 = create_gens(net, [b1], 50, controllable=True)[0] + # controllable should be created with default value False + assert not net.gen.loc[s1, 'controllable'] + assert net.gen.loc[s2, 'controllable'] def test_create_gens_raise_errorexcept(): net = create_empty_network() @@ -1994,6 +2070,8 @@ def test_create_gens_raise_errorexcept(): index=g, ) + validate_network(net) + if __name__ == "__main__": pytest.main([__file__, "-xs"]) diff --git a/pandapower/test/api/test_diagnostic.py b/pandapower/test/api/test_diagnostic.py index 164b03367d..1335fa8039 100644 --- a/pandapower/test/api/test_diagnostic.py +++ b/pandapower/test/api/test_diagnostic.py @@ -114,6 +114,7 @@ def test_greater_zero(self, test_net, diag_params, diag_errors): net.trafo3w.loc[0, "vk_hv_percent"] = 2.3 net.trafo3w.loc[0, "vk_mv_percent"] = np.nan net.trafo3w.loc[0, "vk_lv_percent"] = 0.0 + net.trafo3w.loc[0, "vkr_lv_percent"] = 1.0 net.trafo3w.loc[0, "sn_hv_mva"] = 11 net.trafo3w.loc[0, "sn_mv_mva"] = "a" net.trafo3w.loc[0, "vn_hv_kv"] = -1.5 @@ -137,12 +138,14 @@ def test_greater_zero(self, test_net, diag_params, diag_errors): (0, "vn_hv_kv", -1.5, ">0"), (0, "vn_lv_kv", False, ">0"), (0, "vk_percent", 0.0, ">0"), + (0, 'vkr_percent', 0.06, 'vkr_percent_larger') ], "trafo3w": [ (0, "sn_mv_mva", "a", ">0"), (0, "vn_hv_kv", -1.5, ">0"), (0, "vn_mv_kv", -1.5, ">0"), (0, "vn_lv_kv", False, ">0"), + (0, "vkr_lv_percent", 1.0, "vkr_percent_larger"), (0, "vk_mv_percent", "nan", ">0"), (0, "vk_lv_percent", 0.0, ">0"), (0, "vk_mv_percent", "nan", "<20"), @@ -179,14 +182,18 @@ def test_greater_equal_zero(self, test_net, diag_params, diag_errors): else: diag_results = {} assert diag_results[check_function] == { + "gen": [(0, "scaling", "nan", ">=0")], "line": [ (7, "r_ohm_per_km", -1.0, ">=0"), (8, "x_ohm_per_km", "nan", ">=0"), (8, "c_nf_per_km", "0", ">=0"), ], + "load": [(0, "scaling", -0.1, ">=0"), (3, "scaling", "1", ">=0")], + "sgen": [(0, "scaling", False, ">=0")], "trafo": [ (0, "vkr_percent", "-1", ">=0"), (0, "vkr_percent", "-1", "<15"), + (0, 'vkr_percent', '-1', 'vkr_percent_larger'), (0, "pfe_kw", -1.5, ">=0"), (0, "i0_percent", -0.001, ">=0"), ], @@ -197,9 +204,6 @@ def test_greater_equal_zero(self, test_net, diag_params, diag_errors): (0, "vkr_mv_percent", False, "<15"), (0, "pfe_kw", "2", ">=0"), ], - "gen": [(0, "scaling", "nan", ">=0")], - "load": [(0, "scaling", -0.1, ">=0"), (3, "scaling", "1", ">=0")], - "sgen": [(0, "scaling", False, ">=0")], } check_report_function(diag_function, diag_errors.get(check_function, None), diag_results.get(check_function, None)) diff --git a/pandapower/test/conftest.py b/pandapower/test/conftest.py index 35b904cebd..b24edc309c 100644 --- a/pandapower/test/conftest.py +++ b/pandapower/test/conftest.py @@ -8,8 +8,14 @@ import pytest from pandapower.create import ( - create_empty_network, create_bus, create_ext_grid, create_transformer, create_line, create_load, create_gen, - create_sgen + create_bus, + create_empty_network, + create_ext_grid, + create_gen, + create_line, + create_load, + create_sgen, + create_transformer ) from pandapower.test.loadflow.result_test_network_generator import result_test_network_generator @@ -38,8 +44,8 @@ def simple_network(): net = create_empty_network() b1 = create_bus(net, name="bus1", vn_kv=10.) create_ext_grid(net, b1) - b2 = create_bus(net, name="bus2", geodata=(1, 2)) - b3 = create_bus(net, name="bus3", geodata=(1, 3)) + b2 = create_bus(net, name="bus2", geodata=(1, 2), vn_kv=.4) + b3 = create_bus(net, name="bus3", geodata=(1, 3), vn_kv=.4) b4 = create_bus(net, name="bus4", vn_kv=10.) create_transformer(net, b4, b2, std_type="0.25 MVA 10/0.4 kV", @@ -66,5 +72,12 @@ def result_test_network(): runpp(net, trafo_model="t", trafo_loading="current") return net + +def pytest_generate_tests(metafunc): + if "result_test_networks" in metafunc.fixturenames: + net = result_test_network_generator() + metafunc.parametrize("result_test_networks", net, ids=lambda n: n.last_added_case) + + if __name__ == '__main__': pytest.main([__file__, "-x"]) diff --git a/pandapower/test/consistency_checks.py b/pandapower/test/consistency_checks.py index ccde49cabd..b4089c39cb 100644 --- a/pandapower/test/consistency_checks.py +++ b/pandapower/test/consistency_checks.py @@ -3,7 +3,6 @@ # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. - import pandas as pd from numpy import allclose, isclose import numpy as np @@ -21,21 +20,25 @@ def runpp_with_consistency_checks(net, **kwargs): consistency_checks(net) return True + def runpp_3ph_with_consistency_checks(net, **kwargs): runpp_3ph(net, **kwargs) consistency_checks_3ph(net) return True + def rundcpp_with_consistency_checks(net, **kwargs): rundcpp(net, **kwargs) consistency_checks(net, test_q=False) return True + def runpp_pgm_with_consistency_checks(net): runpp_pgm(net, error_tolerance_vm_pu=1e-11, symmetric=True) consistency_checks(net) return True + def runpp_pgm_3ph_with_consistency_checks(net): runpp_pgm(net, error_tolerance_vm_pu=1e-11, symmetric=False) consistency_checks_3ph(net) diff --git a/pandapower/test/contingency/test_contingency.py b/pandapower/test/contingency/test_contingency.py index 33f7b19378..36d18f261e 100644 --- a/pandapower/test/contingency/test_contingency.py +++ b/pandapower/test/contingency/test_contingency.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import copy +from typing import Callable # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. @@ -82,59 +83,66 @@ def test_contingency_parallel(get_net): report_contingency_results(element_limits, res) -def test_contingency_timeseries(get_net): +@pytest.mark.parametrize("contingency_function", [run_contingency, run_contingency_ls2g]) +def test_contingency_timeseries(get_net, contingency_function): + # FIXME: temporary skip for case14 and run_contingency_ls2g because of error in ls2g + if get_net.name == "case14" and contingency_function == run_contingency_ls2g: + pytest.skip("lightsim2grid's init_ls2g has an error when handling pandapower 4 networks. (pd.NA dtype support missing)") + if not lightsim2grid_installed and contingency_function == run_contingency_ls2g: + pytest.skip("lightsim2grid package is not installed") + nminus1_cases = { element: {"index": get_net[element].index.values} for element in ("line", "trafo") if len(get_net[element]) > 0 } - contingency_functions = [run_contingency] - if lightsim2grid_installed: - contingency_functions = [*contingency_functions, run_contingency_ls2g] - - for contingency_function in contingency_functions: - net0 = copy.deepcopy(get_net) - setup_timeseries(net0) - ow = net0.output_writer.object.at[0] - - run_timeseries( - net0, - time_steps=range(2), - run_control_fct=contingency_function, - nminus1_cases=nminus1_cases, - contingency_evaluation_function=run_for_from_bus_loading, - ) + net0 = copy.deepcopy(get_net) + setup_timeseries(net0) + ow = net0.output_writer.object.at[0] - # check for the last time step: - res1 = run_contingency(net0, nminus1_cases, contingency_evaluation_function=run_for_from_bus_loading) - net1 = copy.deepcopy(net0) - - # check for the first time step: - for c in net0.controller.object.values: - c.time_step(net0, 0) - c.control_step(net0) - res0 = run_contingency(net0, nminus1_cases, contingency_evaluation_function=run_for_from_bus_loading) - - for var in ("vm_pu", "max_vm_pu", "min_vm_pu"): - assert np.allclose(res1["bus"][var], net1.res_bus[var].values, atol=1e-9, rtol=0), var - assert np.allclose(res0["bus"][var], net0.res_bus[var].values, atol=1e-9, rtol=0), var - assert np.allclose(res1["bus"][var], ow.output[f"res_bus.{var}"].iloc[-1, :].values, atol=1e-9, rtol=0), var - assert np.allclose(res0["bus"][var], ow.output[f"res_bus.{var}"].iloc[0, :].values, atol=1e-9, rtol=0), var - for var in ("loading_percent", "max_loading_percent", "min_loading_percent"): - for element in ("line", "trafo"): - if len(net0.trafo) == 0: - continue - assert np.allclose(res1[element][var], net1[f"res_{element}"][var].values, atol=1e-6, rtol=0), var - assert np.allclose(res0[element][var], net0[f"res_{element}"][var].values, atol=1e-6, rtol=0), var - assert np.allclose( - res1[element][var], ow.output[f"res_{element}.{var}"].iloc[-1, :].values, atol=1e-6, rtol=0 - ), var - assert np.allclose( - res0[element][var], ow.output[f"res_{element}.{var}"].iloc[0, :].values, atol=1e-6, rtol=0 - ), var + run_timeseries( + net0, + time_steps=range(2), + run_control_fct=contingency_function, + nminus1_cases=nminus1_cases, + contingency_evaluation_function=run_for_from_bus_loading, + ) + + # check for the last time step: + res1 = run_contingency(net0, nminus1_cases, contingency_evaluation_function=run_for_from_bus_loading) + net1 = copy.deepcopy(net0) + + # check for the first time step: + for c in net0.controller.object.values: + c.time_step(net0, 0) + c.control_step(net0) + res0 = run_contingency(net0, nminus1_cases, contingency_evaluation_function=run_for_from_bus_loading) + + for var in ("vm_pu", "max_vm_pu", "min_vm_pu"): + assert np.allclose(res1["bus"][var], net1.res_bus[var].values, atol=1e-9, rtol=0), var + assert np.allclose(res0["bus"][var], net0.res_bus[var].values, atol=1e-9, rtol=0), var + assert np.allclose(res1["bus"][var], ow.output[f"res_bus.{var}"].iloc[-1, :].values, atol=1e-9, rtol=0), var + assert np.allclose(res0["bus"][var], ow.output[f"res_bus.{var}"].iloc[0, :].values, atol=1e-9, rtol=0), var + for var in ("loading_percent", "max_loading_percent", "min_loading_percent"): + for element in ("line", "trafo"): + if len(net0.trafo) == 0: + continue + assert np.allclose(res1[element][var], net1[f"res_{element}"][var].values, atol=1e-6, rtol=0), var + assert np.allclose(res0[element][var], net0[f"res_{element}"][var].values, atol=1e-6, rtol=0), var + assert np.allclose( + res1[element][var], ow.output[f"res_{element}.{var}"].iloc[-1, :].values, atol=1e-6, rtol=0 + ), var + assert np.allclose( + res0[element][var], ow.output[f"res_{element}.{var}"].iloc[0, :].values, atol=1e-6, rtol=0 + ), var @pytest.mark.skipif(not lightsim2grid_installed, reason="lightsim2grid package is not installed") def test_with_lightsim2grid(get_net, get_case): + # FIXME: temporary skip for case14 because of error in lightsim2grid + if get_net.name == "case14": + pytest.skip( + "lightsim2grid's init_ls2g has an error when handling pandapower 4 networks. (pd.NA dtype support missing)" + ) net = get_net case = get_case rng = np.random.default_rng() @@ -333,9 +341,10 @@ def test_lightsim2grid_phase_shifters(): bus_res = net.res_bus.copy() if "tap_phase_shifter" in net.trafo.columns: _convert_trafo_phase_shifter(net, "trafo", "tap_phase_shifter") - if ("tap_changer_type" in net.trafo.columns) or ("tap_changer_type" in net.trafo3w.columns): + if "tap_changer_type" in net.trafo.columns: if np.any(net.trafo.tap_changer_type == "Ideal"): _convert_trafo_phase_shifter(net, "trafo", "tap_changer_type") + if "tap_changer_type" in net.trafo3w.columns: if np.any(net.trafo3w.tap_changer_type == "Ideal"): _convert_trafo_phase_shifter(net, "trafo3w", "tap_changer_type") runpp(net) @@ -344,6 +353,10 @@ def test_lightsim2grid_phase_shifters(): @pytest.mark.skipif(not lightsim2grid_installed, reason="lightsim2grid package is not installed") def test_cause_congestion(): + # FIXME: temporary skip for case14 because of error in lightsim2grid + pytest.skip( + "lightsim2grid's init_ls2g has an error when handling pandapower 4 networks. (pd.NA dtype support missing)" + ) net = case14() for c in ("tap_neutral", "tap_step_percent", "tap_pos", "tap_step_degree"): net.trafo[c] = 0 @@ -423,6 +436,10 @@ def test_cause_element_index(): check_cause_index(net, nminus1_cases) if lightsim2grid_installed: + # FIXME: temporary skip for case14 because of error in lightsim2grid + pytest.skip( + "lightsim2grid's init_ls2g has an error when handling pandapower 4 networks. (pd.NA dtype support missing)" + ) run_contingency_ls2g(net, nminus1_cases, contingency_evaluation_function=run_for_from_bus_loading) columns = [ diff --git a/pandapower/test/control/test_der_control.py b/pandapower/test/control/test_der_control.py index 1b156169e4..7f22893585 100644 --- a/pandapower/test/control/test_der_control.py +++ b/pandapower/test/control/test_der_control.py @@ -32,7 +32,7 @@ def simple_test_net(): net = create_empty_network() create_buses(net, 2, vn_kv=20) create_ext_grid(net, 0) - create_sgen(net, 1, p_mw=2., sn_mva=3, name="DER1") + create_sgen(net, 1, p_mw=2., sn_mva=3, name="DER1", type='wye') create_line(net, 0, 1, length_km=0.1, std_type="NAYY 4x50 SE") return net @@ -41,7 +41,7 @@ def simple_test_net2(): net = simple_test_net() bus = create_bus(net, vn_kv=20) create_line(net, 0, bus, 0.1, std_type="NAYY 4x50 SE") - create_sgen(net, bus, 2., sn_mva=3., name="DER2") + create_sgen(net, bus, 2., sn_mva=3., name="DER2", type='wye') return net diff --git a/pandapower/test/control/test_stactrl.py b/pandapower/test/control/test_stactrl.py index 40236a0eae..d25b1bf4f1 100644 --- a/pandapower/test/control/test_stactrl.py +++ b/pandapower/test/control/test_stactrl.py @@ -8,6 +8,7 @@ import logging import numpy as np from pandapower.control.controller.station_control import BinarySearchControl, DroopControl +from pandapower.create._utils import add_column_to_df from pandapower.create import create_empty_network, create_bus, create_buses, create_ext_grid, create_transformer, \ create_load, create_line, create_sgen, create_impedance from pandapower.run import runpp @@ -18,6 +19,7 @@ from numpy import linspace, float64 from pandas import DataFrame +import pandas as pd logger = logging.getLogger(__name__) @@ -190,6 +192,7 @@ def test_qlimits_voltctrl(): assert(getattr(net.controller.at[0, 'object'].control_modus, 'value', None) == 'V_ctrl') assert(all(net.controller.object[i].converged == True for i in net.controller.index)) + @pytest.mark.parametrize("v", linspace(start=0.98, stop=1.02, num=5, dtype=float64)) @pytest.mark.parametrize("p", linspace(start=-2.5, stop=2.5, num=10, dtype=float64)) def test_qlimits_with_capability_curve(v, p): @@ -202,10 +205,14 @@ def test_qlimits_with_capability_curve(v, p): 'p_mw': [-2.0, -1.0, 0.0, 1.0, 2.0], 'q_min_mvar': [-0.1, -0.1, -0.1, -0.1, -0.1], 'q_max_mvar': [0.1, 0.1, 0.1, 0.1, 0.1]}) - + add_column_to_df(net, "sgen", "id_q_capability_characteristic") + add_column_to_df(net, "sgen", "reactive_capability_curve") + add_column_to_df(net, "sgen", "curve_style") net.sgen.at[0, "id_q_capability_characteristic"] = 0 net.sgen['curve_style'] = "straightLineYValues" create_q_capability_characteristics_object(net) + # min_q_mvar and max_q_mvar columns required for BinarySearchControl to work correctly + # (see station_control.py _update_min_max_q_mvar function) BinarySearchControl(net, name="BSC1", ctrl_in_service=True, output_element="sgen", output_variable="q_mvar", output_element_index=[0], output_element_in_service=[True], output_values_distribution=[1], diff --git a/pandapower/test/control/test_tap_dependent_impedance.py b/pandapower/test/control/test_tap_dependent_impedance.py index 70702a3163..f440673f06 100644 --- a/pandapower/test/control/test_tap_dependent_impedance.py +++ b/pandapower/test/control/test_tap_dependent_impedance.py @@ -6,6 +6,7 @@ import pytest import numpy as np +from pandapower.create._utils import add_column_to_df from pandapower.control import Characteristic, SplineCharacteristic, TapDependentImpedance, \ trafo_characteristic_table_diagnostic from pandapower.control.util.diagnostic import shunt_characteristic_table_diagnostic @@ -136,8 +137,10 @@ def test_trafo_characteristic_table_diagnostic(): 'vkr_percent': [1.3, 1.4, 1.44, 1.5, 1.6], 'vk_hv_percent': np.nan, 'vkr_hv_percent': np.nan, 'vk_mv_percent': np.nan, 'vkr_mv_percent': np.nan, 'vk_lv_percent': np.nan, 'vkr_lv_percent': np.nan}) # populate id_characteristic_table parameter - net.trafo.at[0, 'id_characteristic_table'] = 0 - net.trafo.at[0, 'tap_dependency_table'] = False + add_column_to_df(net, "trafo", "id_characteristic_table") + add_column_to_df(net, "trafo", 'tap_dependency_table') + net.trafo['id_characteristic_table'].at[0] = 0 + net.trafo['tap_dependency_table'].at[0] = False with pytest.warns(UserWarning): trafo_characteristic_table_diagnostic(net) # populate tap_dependency_table parameter @@ -177,20 +180,22 @@ def test_shunt_characteristic_table_diagnostic(): create_transformer(net, hv_bus=b2, lv_bus=cb, std_type="0.25 MVA 20/0.4 kV", tap_pos=2) # initially no shunt_characteristic_table is available - assert shunt_characteristic_table_diagnostic(net) is False + assert not shunt_characteristic_table_diagnostic(net) # add shunt_characteristic_table net["shunt_characteristic_table"] = pd.DataFrame( {'id_characteristic': [0, 0, 0, 0, 0], 'step': [1, 2, 3, 4, 5], 'q_mvar': [-25, -55, -75, -120, -125], 'p_mw': [1, 1.5, 3, 4.5, 5]}) # populate id_characteristic_table parameter - net.shunt.at[0, 'id_characteristic_table'] = 0 - net.shunt.at[0, 'step_dependency_table'] = False + add_column_to_df(net, "shunt", "id_characteristic_table") + net.shunt.at[0, "id_characteristic_table"] = 0 + add_column_to_df(net, "shunt", "step_dependency_table") + net.shunt.at[0, "step_dependency_table"] = False with pytest.warns(UserWarning): shunt_characteristic_table_diagnostic(net) # populate step_dependency_table parameter net.shunt.at[0, 'step_dependency_table'] = True - assert shunt_characteristic_table_diagnostic(net) is True + assert shunt_characteristic_table_diagnostic(net) # add shunt_characteristic_table with missing parameter values net["shunt_characteristic_table"] = pd.DataFrame( diff --git a/pandapower/test/converter/test_from_cim.py b/pandapower/test/converter/test_from_cim.py index 3d87fe95e8..6f3667c8a3 100644 --- a/pandapower/test/converter/test_from_cim.py +++ b/pandapower/test/converter/test_from_cim.py @@ -109,7 +109,7 @@ def SimBench_1_HVMVmixed_1_105_0_sw_modified(): cgmes_files = [os.path.join(folder_path, 'SimBench_1-HVMV-mixed-1.105-0-sw_modified.zip')] - return from_cim(file_list=cgmes_files, run_powerflow=True) + return from_cim(file_list=cgmes_files, run_powerflow=True, ignore_errors=False) @pytest.fixture(scope="module") @@ -118,7 +118,7 @@ def Simbench_1_EHV_mixed__2_no_sw(): cgmes_files = [os.path.join(folder_path, 'Simbench_1-EHV-mixed--2-no_sw.zip')] - return from_cim(file_list=cgmes_files, create_measurements='SV', run_powerflow=True) + return from_cim(file_list=cgmes_files, create_measurements='SV', run_powerflow=True, ignore_errors=False) @pytest.fixture(scope="module") @@ -1044,21 +1044,16 @@ def test_fullgrid_measurement(fullgrid_v2): assert len(fullgrid_v2.measurement.index) == 0 # TODO: analogs -def test_fullgrid_load(fullgrid_v2): +def test_fullgrid_load(fullgrid_v2): # TODO: test each load type assert len(fullgrid_v2.load.index) == 5 element_0 = fullgrid_v2.load[fullgrid_v2.load['origin_id'] == '_1324b99a-59ee-0d44-b1f6-15dc0d9d81ff'] assert element_0['name'].item() == 'BE_CL_1' assert fullgrid_v2.bus.iloc[element_0['bus'].item()]['origin_id'] == '_4c66b132-0977-1e4c-b9bb-d8ce2e912e35' assert element_0['p_mw'].item() == pytest.approx(0.010, abs=0.000001) assert element_0['q_mvar'].item() == pytest.approx(0.010, abs=0.000001) - assert element_0['const_z_p_percent'].item() == pytest.approx(0.0, abs=0.000001) - assert element_0['const_i_p_percent'].item() == pytest.approx(0.0, abs=0.000001) - assert element_0['const_z_q_percent'].item() == pytest.approx(0.0, abs=0.000001) - assert element_0['const_i_q_percent'].item() == pytest.approx(0.0, abs=0.000001) assert math.isnan(element_0['sn_mva'].item()) assert element_0['scaling'].item() == pytest.approx(1.0, abs=0.000001) assert element_0['in_service'].item() - assert None is element_0['type'].item() assert element_0['origin_class'].item() == 'ConformLoad' assert element_0['terminal'].item() == '_84f6ff75-6bf9-8742-ae06-1481aa3b34de' @@ -1073,7 +1068,6 @@ def test_fullgrid_line(fullgrid_v2): assert len(fullgrid_v2.line.index) == 11 element_0 = fullgrid_v2.line[fullgrid_v2.line['origin_id'] == '_a16b4a6c-70b1-4abf-9a9d-bd0fa47f9fe4'] assert element_0['name'].item() == 'BE-Line_7' - assert None is element_0['std_type'].item() assert fullgrid_v2.bus.iloc[element_0['from_bus'].item()]['origin_id'] == '_1fa19c281c8f4e1eaad9e1cab70f923e' assert fullgrid_v2.bus.iloc[element_0['to_bus'].item()]['origin_id'] == '_f70f6bad-eb8d-4b8f-8431-4ab93581514e' assert element_0['length_km'].item() == pytest.approx(23.0, abs=0.000001) @@ -1084,7 +1078,6 @@ def test_fullgrid_line(fullgrid_v2): assert element_0['max_i_ka'].item() == pytest.approx(1.0620, abs=0.000001) assert element_0['df'].item() == pytest.approx(1.0, abs=0.000001) assert element_0['parallel'].item() == pytest.approx(1.0, abs=0.000001) - assert None is element_0['type'].item() assert element_0['in_service'].item() assert element_0['origin_class'].item() == 'ACLineSegment' assert element_0['terminal_from'].item() == '_57ae9251-c022-4c67-a8eb-611ad54c963c' @@ -1316,7 +1309,7 @@ def test_fullgrid_bus(fullgrid_v2): element_2 = fullgrid_v2.bus[fullgrid_v2.bus['origin_id'] == '_99b219f3-4593-428b-a4da-124a54630178'] assert element_2['zone'].item() == 'PP_Brussels' - assert math.isnan(element_2['geo'].item()) + assert pd.isna(element_2['geo'].item()) assert element_2['cim_topnode'].item() == '_99b219f3-4593-428b-a4da-124a54630178' assert element_2['ConnectivityNodeContainer_id'].item() == '_b10b171b-3bc5-4849-bb1f-61ed9ea1ec7c' assert element_2['Substation_id'].item() == '_37e14a0f-5e34-4647-a062-8bfd9305fa9d' @@ -1364,7 +1357,7 @@ def test_fullgrid_NB_bus(fullgrid_node_breaker): element_2 = fullgrid_node_breaker.bus[fullgrid_node_breaker.bus['origin_id'] == '_c38adab3-5168-4004-a83d-28d890dedd36'] assert element_2['zone'].item() == 'HVDC 1' - assert math.isnan(element_2['geo'].item()) + assert pd.isna(element_2['geo'].item()) assert element_2['cim_topnode'].item() == '_b01fe92f-68ab-4123-ae45-f22d3e8daad1' assert element_2['ConnectivityNodeContainer_id'].item() == '_c68f0a24-46cb-42aa-b91d-0b49b8310cc9' assert element_2['Substation_id'].item() == '_9df6213f-c5dc-477c-aab4-74721f7d1fdb' diff --git a/pandapower/test/converter/test_from_mpc.py b/pandapower/test/converter/test_from_mpc.py index da9f1935df..61fa591762 100644 --- a/pandapower/test/converter/test_from_mpc.py +++ b/pandapower/test/converter/test_from_mpc.py @@ -13,6 +13,7 @@ from pandapower.networks import case24_ieee_rts from pandapower.run import runpp, set_user_pf_options from pandapower.toolbox.comparison import nets_equal +from pandapower.results import reset_results try: import matpowercaseframes @@ -32,6 +33,10 @@ def test_from_mpc_mat(): this_folder = os.path.join(pp_dir, "test", "converter") mat_case = os.path.join(this_folder, 'case24_ieee_rts.mat') case24_from_mpc = from_mpc(mat_case, f_hz=60, casename_mpc_file='mpc', tap_side="hv") + # TODO: remove after https://github.com/e2nIEE/pandapower/pull/2813: + # reset 3ph results (new columns in case24 but not in from_mpc, would be solved by 3ph powerflow, so not relevant) + reset_results(case24, "pf_3ph") + reset_results(case24_from_mpc, "pf_3ph") runpp(case24) runpp(case24_from_mpc) diff --git a/pandapower/test/converter/test_from_ppc.py b/pandapower/test/converter/test_from_ppc.py index 95f88c5912..125de89b9e 100644 --- a/pandapower/test/converter/test_from_ppc.py +++ b/pandapower/test/converter/test_from_ppc.py @@ -9,7 +9,7 @@ import numpy as np import pandas as pd -from pandapower import pp_dir +from pandapower import pp_dir, reset_results from pandapower.file_io import from_json from pandapower.toolbox.data_modification import reindex_buses from pandapower.toolbox.comparison import nets_equal @@ -126,6 +126,11 @@ def test_to_and_from_ppc(): # again add max_loading_percent to enable valid comparison net2.line["max_loading_percent"] = 100 + # TODO: remove after https://github.com/e2nIEE/pandapower/pull/2813: + # reset 3ph results (new columns not in net but in ppc, would be solved by 3ph powerflow, so not relevant) + reset_results(net, "pf_3ph") + reset_results(net2, "pf_3ph") + # compare loadflow results runpp(net) runpp(net2) diff --git a/pandapower/test/converter/test_from_ucte.py b/pandapower/test/converter/test_from_ucte.py index 836b86588b..f375c4bf53 100644 --- a/pandapower/test/converter/test_from_ucte.py +++ b/pandapower/test/converter/test_from_ucte.py @@ -106,7 +106,7 @@ def test_from_ucte(test_case): atol_dict = { "res_bus": {"vm_pu": 1e-4, "va_degree": 7e-3}, "res_line": {"p_from_mw": 5e-2, "q_from_mvar": 2e-1}, - "res_trafo": {"p_hv_mw": 5e-2, "q_hv_mvar": 1e-1}, + "res_trafo": {"p_hv_mw": 1e-3, "q_hv_mvar": 1e-3}, } if test_case == "test_ucte_xward": atol_dict["res_line"]["q_from_mvar"] = 0.8 # xwards are converted as @@ -143,8 +143,8 @@ def test_from_ucte(test_case): # --- compare the results itself all_close = all([np.allclose( - df_after_conversion[col].values, - df_target.loc[df_after_conversion.index, col].values, atol=atol) for col, atol in + df_after_conversion[col].to_numpy(), + df_target.loc[df_after_conversion.index, col].to_numpy(), atol=atol) for col, atol in atol_dict[res_et].items()]) if not all_close: logger.error(f"{res_et=} comparison fails due to different values.\n{df_str}") diff --git a/pandapower/test/converter/testfiles/test_ucte_res_trafo.csv b/pandapower/test/converter/testfiles/test_ucte_res_trafo.csv index 1ece94888d..e6e22cea09 100644 --- a/pandapower/test/converter/testfiles/test_ucte_res_trafo.csv +++ b/pandapower/test/converter/testfiles/test_ucte_res_trafo.csv @@ -3,4 +3,4 @@ trafo1_DE;0.059997;0.07493 trafo1_RS;200.485413;68.527137 trafo2_RS;0;0 trafo3_RS;0;0 -trafo3w_FR;-2.13403;-0.06015863 +trafo3w_FR;2.13403;0.06015863 diff --git a/pandapower/test/grid_equivalents/test_get_equivalent.py b/pandapower/test/grid_equivalents/test_get_equivalent.py index bbcf3f911b..4eb75892c3 100644 --- a/pandapower/test/grid_equivalents/test_get_equivalent.py +++ b/pandapower/test/grid_equivalents/test_get_equivalent.py @@ -8,6 +8,7 @@ from pandapower import pp_dir from pandapower.control import ConstControl from pandapower.control.util.auxiliary import create_trafo_characteristic_object +from pandapower.create._utils import add_column_to_df from pandapower.create import create_empty_network, create_buses, create_ext_grid, create_poly_cost, create_line, \ create_load, create_sgen, create_pwl_cost, create_bus, create_switch, create_motor from pandapower.grid_equivalents.auxiliary import replace_motor_by_load, _runpp_except_voltage_angles @@ -29,8 +30,8 @@ def create_test_net(): net = create_empty_network() # buses - create_buses(net, 7, 20, zone=[0, 0, 1, 1, 1, 0, 0], name=["bus %i" % i for i in range(7)], - min_vm_pu=np.append(np.arange(.9, 0.94, .01), [np.nan, np.nan, np.nan])) + create_buses(net, 7, 20, zone=['0', '0', '1', '1', '1', '0', '0'], name=[f"bus {i}" for i in range(7)], + min_vm_pu=np.append(np.arange(.9, 0.94, .01), [np.nan, np.nan, np.nan]), max_vm_pu=2.0) # ext_grid idx = create_ext_grid(net, 0, 1.0, 0.0) @@ -162,8 +163,7 @@ def test_cost_consideration(): boundary_buses = [0, 2] internal_buses = [1] eq_net1 = get_equivalent(net, "rei", boundary_buses, internal_buses) - eq_net2 = get_equivalent(net, "rei", boundary_buses, internal_buses, - return_internal=False) + eq_net2 = get_equivalent(net, "rei", boundary_buses, internal_buses, return_internal=False) # check elements check_elements_amount(eq_net1, {"bus": 6, "load": 3, "sgen": 3, "shunt": 5, "ext_grid": 1, @@ -218,18 +218,15 @@ def test_basic_usecases(eq_type): check_elements_amount(net1, {"bus": 5, "load": 3, "sgen": 2, "shunt": 3, "ext_grid": 1, "line": 3, "impedance": 3}, check_all_pp_elements=True) check_res_bus(net, net1) - assert np.allclose(net1.bus.min_vm_pu.values, - np.array([0.9, 0.91, np.nan, np.nan, 0.93]), equal_nan=True) + assert np.allclose(net1.bus.min_vm_pu.to_numpy(), np.array([0.9, 0.91, 0., 0., 0.93])) check_elements_amount(net2, {"bus": 3, "load": 3, "sgen": 0, "shunt": 3, "ext_grid": 0, "line": 0, "impedance": 2}, check_all_pp_elements=True) check_res_bus(net, net2) - assert np.allclose(net2.bus.min_vm_pu.values, - net.bus.min_vm_pu.loc[[2, 4, 3]].values, equal_nan=True) + assert np.allclose(net2.bus.min_vm_pu.to_numpy(), net.bus.min_vm_pu.loc[[2, 4, 3]].to_numpy()) check_elements_amount(net3, {"bus": 5, "load": 3, "sgen": 2, "shunt": 3, "ext_grid": 1, "line": 3, "impedance": 3}, check_all_pp_elements=True) check_res_bus(net, net3) - assert np.allclose(net1.bus.min_vm_pu.values, - np.array([0.9, 0.91, np.nan, np.nan, 0.93]), equal_nan=True) + assert np.allclose(net1.bus.min_vm_pu.to_numpy(), np.array([0.9, 0.91, 0., 0., 0.93])) elif "ward" in eq_type: check_elements_amount(net1, {"bus": 4, "load": 2, "sgen": 2, "ext_grid": 1, "line": 3, @@ -356,7 +353,6 @@ def test_adopt_columns_to_separated_eq_elms(): def test_equivalent_groups(): net = example_multivoltage() - # net.sn_mva = 100 for elm in pp_elements(): if net[elm].shape[0] and not net[elm].name.duplicated().any(): net[elm]["origin_id"] = net[elm].name @@ -488,6 +484,8 @@ def test_characteristic(): 'angle_deg': [0, 0, 0, 0, 0], 'vk_percent': [2, 3, 4, 5, 6], 'vkr_percent': [1.323, 1.324, 1.325, 1.326, 1.327], 'vk_hv_percent': np.nan, 'vkr_hv_percent': np.nan, 'vk_mv_percent': np.nan, 'vkr_mv_percent': np.nan, 'vk_lv_percent': np.nan, 'vkr_lv_percent': np.nan}) + add_column_to_df(net, "trafo", "id_characteristic_table") + add_column_to_df(net, "trafo", 'tap_dependency_table') net.trafo.at[1, 'id_characteristic_table'] = 0 net.trafo.at[1, 'tap_dependency_table'] = True # add spline characteristics for one transformer based on trafo_characteristic_table diff --git a/pandapower/test/grid_equivalents/test_grid_equivalents_auxiliary.py b/pandapower/test/grid_equivalents/test_grid_equivalents_auxiliary.py index 122c9bb2da..b6fb2c1aab 100644 --- a/pandapower/test/grid_equivalents/test_grid_equivalents_auxiliary.py +++ b/pandapower/test/grid_equivalents/test_grid_equivalents_auxiliary.py @@ -45,10 +45,10 @@ def test_trafo_phase_shifter(): runpp(net) net_eq = get_equivalent(net, "rei", [4, 8], [0], retain_original_internal_indices=True) - v, p = get_boundary_vp(net_eq, net_eq.bus_lookups) + v, _ = get_boundary_vp(net_eq, net_eq.bus_lookups) net.res_bus.vm_pu = net.res_bus.vm_pu.values + 1e-3 net.res_bus.va_degree = net.res_bus.va_degree.values + 1e-3 - adaptation_phase_shifter(net, v, p) + adaptation_phase_shifter(net, v) assert len(net.trafo) == 3 diff --git a/pandapower/test/grid_equivalents/test_grid_equivalents_toolbox.py b/pandapower/test/grid_equivalents/test_grid_equivalents_toolbox.py index 6f1bb58a62..56377a7dde 100644 --- a/pandapower/test/grid_equivalents/test_grid_equivalents_toolbox.py +++ b/pandapower/test/grid_equivalents/test_grid_equivalents_toolbox.py @@ -1,6 +1,9 @@ +from collections.abc import Callable + import numpy as np import pytest +from pandapower import pandapowerNet from pandapower.convert_format import convert_format from pandapower.create import ( create_bus, @@ -20,101 +23,111 @@ from pandapower.toolbox.grid_modification import replace_ext_grid_by_gen, merge_nets -def boundary_testnet(which): - if which == "case9_27": - net = case9() - expected_bbr = {"line": {2, 7}} - expected_bb = { - "all": {4, 5, 7, 8}, - 0: {"all": {4, 5, 7, 8}, "internal": {4, 8}, "external": {5, 7}, 1: {5, 7}}, - 1: {"all": {4, 5, 7, 8}, "internal": {5, 7}, "external": {4, 8}, 0: {4, 8}}, - } - elif which == "case9_abc": - net = case9() - net.bus["zone"] = ["a", "b", "c", "a", "a", "c", "c", "b", "b"] - expected_bbr = {"all": {"line": {2, 5, 8}}, "a": {"line": {2, 8}}, "b": {"line": {5, 8}}, "c": {"line": {2, 5}}} - expected_bb = {"a": {"internal": {3, 4}, "external": {5, 8}}} - elif which == "case9_ab_merged": - net1 = case9() - net1.bus["zone"] = "a" - net2 = case9() - net2.bus["zone"] = "b" - net2.ext_grid["p_disp_mw"] = 71.9547 - replace_ext_grid_by_gen(net2) - net = merge_nets(net1, net2, merge_results=False, validate=False, net2_reindex_log_level=None) - new_bus = create_bus(net, 345, zone="b") - - # expected_bbr - expected_bbr = { - "line": { - create_line_from_parameters( - net, net.bus.index[net.bus.name == 9][0], net.bus.index[net.bus.name == 9][1], 1, 0, 65, 0, 0.41 - ) - }, - "impedance": { - create_impedance( - net, net.bus.index[net.bus.name == 5][0], net.bus.index[net.bus.name == 5][1], 0, 0.06, 250 - ) - }, - "switch": { - create_switch(net, net.bus.index[net.bus.name == 7][0], net.bus.index[net.bus.name == 7][1], "b") - }, - "trafo": { - create_transformer_from_parameters( - net, - net.bus.index[net.bus.name == 8][0], - net.bus.index[net.bus.name == 8][1], - 250, - 345, - 345, - 0, - 10, - 50, - 0, - ) - }, - "trafo3w": { - create_transformer3w_from_parameters( - net, - net.bus.index[net.bus.name == 3][0], - new_bus, - net.bus.index[net.bus.name == 3][1], - 345, - 345, - 345, - 250, - 250, - 250, - 10, - 10, - 10, - 0, - 0, - 0, - 50, - 0, - ) - }, - } - - # expected_bb - expected_bb = {key: {} for key in ["a", "b"]} - expected_bb["a"]["internal"] = set(net.bus.index[net.bus.name.isin([9, 5, 7, 8, 3]) & (net.bus.zone == "a")]) - expected_bb["a"]["external"] = set( - net.bus.index[net.bus.name.isin([9, 5, 7, 8, 3]) & (net.bus.zone == "b")] - ) | {new_bus} - expected_bb["b"]["internal"] = expected_bb["a"]["external"] - {18} - expected_bb["b"]["external"] = expected_bb["a"]["internal"] | {18} +def case9_27() -> tuple[pandapowerNet, dict, dict]: + net = case9() + expected_bbr = {"line": {2, 7}} + expected_bb = { + "all": {4, 5, 7, 8}, + '0': {"all": {4, 5, 7, 8}, "internal": {4, 8}, "external": {5, 7}, '1': {5, 7}}, + '1': {"all": {4, 5, 7, 8}, "internal": {5, 7}, "external": {4, 8}, '0': {4, 8}}, + } + net = convert_format(net) + runpp(net) + return net, expected_bb, expected_bbr + + +def case9_abc() -> tuple[pandapowerNet, dict, dict]: + net = case9() + net.bus["zone"] = ["a", "b", "c", "a", "a", "c", "c", "b", "b"] + expected_bbr = {"all": {"line": {2, 5, 8}}, "a": {"line": {2, 8}}, "b": {"line": {5, 8}}, "c": {"line": {2, 5}}} + expected_bb = {"a": {"internal": {3, 4}, "external": {5, 8}}} + net = convert_format(net) + runpp(net) + return net, expected_bb, expected_bbr + +def case9_ab_merged() -> tuple[pandapowerNet, dict, dict]: + net1 = case9() + net1.bus["zone"] = "a" + net2 = case9() + net2.bus["zone"] = "b" + net2.ext_grid["p_disp_mw"] = 71.9547 + replace_ext_grid_by_gen(net2) + net = merge_nets(net1, net2, merge_results=False, validate=False, net2_reindex_log_level=None) + new_bus = create_bus(net, 345, zone="b") + + # expected_bbr + expected_bbr = { + "line": { + create_line_from_parameters( + net, net.bus.index[net.bus.name == '9'][0], net.bus.index[net.bus.name == '9'][1], 1, 0, 65, 0, 0.41 + ) + }, + "impedance": { + create_impedance( + net, net.bus.index[net.bus.name == '5'][0], net.bus.index[net.bus.name == '5'][1], 0, 0.06, 250 + ) + }, + "switch": { + create_switch(net, net.bus.index[net.bus.name == '7'][0], net.bus.index[net.bus.name == '7'][1], "b") + }, + "trafo": { + create_transformer_from_parameters( + net, + net.bus.index[net.bus.name == '8'][0], + net.bus.index[net.bus.name == '8'][1], + 250, + 345, + 345, + 0, + 10, + 50, + 0, + ) + }, + "trafo3w": { + create_transformer3w_from_parameters( + net, + net.bus.index[net.bus.name == '3'][0], + new_bus, + net.bus.index[net.bus.name == '3'][1], + 345, + 345, + 345, + 250, + 250, + 250, + 10, + 10, + 10, + 0, + 0, + 0, + 50, + 0, + ) + }, + } + + # expected_bb + expected_bb = { + "a": { + "internal": set(net.bus.index[net.bus.name.isin(['9', '5', '7', '8', '3']) & (net.bus.zone == "a")]), + "external": set(net.bus.index[net.bus.name.isin(['9', '5', '7', '8', '3']) & (net.bus.zone == "b")]) | {new_bus} + }, + "b": {} + } + expected_bb["b"]["internal"] = expected_bb["a"]["external"] - {18} + expected_bb["b"]["external"] = expected_bb["a"]["internal"] | {18} net = convert_format(net) runpp(net) return net, expected_bb, expected_bbr def test_set_bus_zone_by_boundary_branches_and_get_boundaries_by_bus_zone_with_boundary_branches1(): - net, expected_bb, expected_bbr = boundary_testnet("case9_27") + net, expected_bb, expected_bbr = case9_27() set_bus_zone_by_boundary_branches(net, expected_bbr) - assert all(net.bus.zone.values == np.array([0, 1, 1, 0, 0, 1, 1, 1, 0])) + assert all(net.bus.zone.values == np.array(['0', '1', '1', '0', '0', '1', '1', '1', '0'])) boundary_buses, boundary_branches = get_boundaries_by_bus_zone_with_boundary_branches(net) @@ -124,11 +137,11 @@ def test_set_bus_zone_by_boundary_branches_and_get_boundaries_by_bus_zone_with_b # --- test against set_bus_zone_by_boundary_branches() bb_in = {"line": {2, 4, 7}} set_bus_zone_by_boundary_branches(net, bb_in) - assert all(net.bus.zone.values == np.array([0, 1, 2, 0, 0, 2, 1, 1, 0])) + assert all(net.bus.zone.values == np.array(['0', '1', '2', '0', '0', '2', '1', '1', '0'])) def test_set_bus_zone_by_boundary_branches_and_get_boundaries_by_bus_zone_with_boundary_branches2(): - net, expected_bb, expected_bbr = boundary_testnet("case9_abc") + net, expected_bb, expected_bbr = case9_abc() boundary_buses, boundary_branches = get_boundaries_by_bus_zone_with_boundary_branches(net) assert len(boundary_buses.keys()) == 4 @@ -140,7 +153,7 @@ def test_set_bus_zone_by_boundary_branches_and_get_boundaries_by_bus_zone_with_b def test_set_bus_zone_by_boundary_branches_and_get_boundaries_by_bus_zone_with_boundary_branches3(): - net, expected_bb, expected_bbr = boundary_testnet("case9_ab_merged") + net, expected_bb, expected_bbr = case9_ab_merged() boundary_buses, boundary_branches = get_boundaries_by_bus_zone_with_boundary_branches(net) # --- check form of boundary_buses diff --git a/pandapower/test/loadflow/result_test_network_generator.py b/pandapower/test/loadflow/result_test_network_generator.py index 5f7f8167ca..6f8ac2d35c 100644 --- a/pandapower/test/loadflow/result_test_network_generator.py +++ b/pandapower/test/loadflow/result_test_network_generator.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import copy # Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics # and Energy System Technology (IEE), Kassel. All rights reserved. @@ -23,63 +24,53 @@ ) from pandapower.test.helper_functions import add_grid_connection, create_test_line - -def result_test_network_generator2(net, sn_mva=1, skip_test_impedance=False): - """This is a generator for the result_test_network - It is structured like this so it can be tested for consistency at - different stages of adding elements - """ - yield add_test_trafo(net) - # yield add_test_line(net) - yield add_test_load_sgen(net) - yield add_test_load_sgen_split(net) - yield add_test_ext_grid(net) - yield add_test_trafo(net) - yield add_test_single_load_single_eg(net) - yield add_test_ward(net) - yield add_test_ward_split(net) - yield add_test_xward(net) - yield add_test_xward_combination(net) - yield add_test_gen(net) - yield add_test_ext_grid_gen_switch(net) - yield add_test_enforce_qlims(net) - yield add_test_trafo3w(net) - if not skip_test_impedance: - yield add_test_impedance(net) - yield add_test_bus_bus_switch(net) - yield add_test_oos_bus_with_is_element(net) - yield add_test_shunt(net) - yield add_test_shunt_split(net) - yield add_test_two_open_switches_on_deactive_line(net) - - def result_test_network_generator(sn_mva=1, skip_test_impedance=False): """This is a generator for the result_test_network It is structured like this so it can be tested for consistency at different stages of adding elements """ net = create_empty_network(sn_mva=sn_mva) - yield add_test_line(net) - yield add_test_load_sgen(net) - yield add_test_load_sgen_split(net) - yield add_test_ext_grid(net) - yield add_test_trafo(net) - yield add_test_single_load_single_eg(net) - yield add_test_ward(net) - yield add_test_ward_split(net) - yield add_test_xward(net) - yield add_test_xward_combination(net) - yield add_test_gen(net) - yield add_test_ext_grid_gen_switch(net) - yield add_test_enforce_qlims(net) - yield add_test_trafo3w(net) + add_test_line(net) + yield copy.deepcopy(net) + add_test_load_sgen(net) + yield copy.deepcopy(net) + add_test_load_sgen_split(net) + yield copy.deepcopy(net) + add_test_ext_grid(net) + yield copy.deepcopy(net) + add_test_trafo(net) + yield copy.deepcopy(net) + add_test_single_load_single_eg(net) + yield copy.deepcopy(net) + add_test_ward(net) + yield copy.deepcopy(net) + add_test_ward_split(net) + yield copy.deepcopy(net) + add_test_xward(net) + yield copy.deepcopy(net) + add_test_xward_combination(net) + yield copy.deepcopy(net) + add_test_gen(net) + yield copy.deepcopy(net) + add_test_ext_grid_gen_switch(net) + yield copy.deepcopy(net) + add_test_enforce_qlims(net) + yield copy.deepcopy(net) + add_test_trafo3w(net) + yield copy.deepcopy(net) if not skip_test_impedance: - yield add_test_impedance(net) - yield add_test_bus_bus_switch(net) - yield add_test_oos_bus_with_is_element(net) - yield add_test_shunt(net) - yield add_test_shunt_split(net) - yield add_test_two_open_switches_on_deactive_line(net) + add_test_impedance(net) + yield copy.deepcopy(net) + add_test_bus_bus_switch(net) + yield copy.deepcopy(net) + add_test_oos_bus_with_is_element(net) + yield copy.deepcopy(net) + add_test_shunt(net) + yield copy.deepcopy(net) + add_test_shunt_split(net) + yield copy.deepcopy(net) + add_test_two_open_switches_on_deactive_line(net) + yield copy.deepcopy(net) def result_test_network_generator_dcpp(sn_mva=1): @@ -93,23 +84,14 @@ def result_test_network_generator_dcpp(sn_mva=1): yield add_test_line(net) yield add_test_load_sgen(net) yield add_test_load_sgen_split(net) - # yield add_test_ext_grid(net) - # yield add_test_trafo(net) yield add_test_single_load_single_eg(net) yield add_test_ward(net) yield add_test_ward_split(net) yield add_test_xward(net) yield add_test_xward_combination(net) - # yield add_test_gen(net) - # yield add_test_ext_grid_gen_switch(net) - # yield add_test_enforce_qlims(net) - # yield add_test_trafo3w(net) - # yield add_test_impedance(net) yield add_test_bus_bus_switch(net) - # yield add_test_oos_bus_with_is_element(net) yield add_test_shunt(net) yield add_test_shunt_split(net) - # yield add_test_two_open_switches_on_deactive_line(net) def add_test_line(net): @@ -284,7 +266,6 @@ def add_test_ward_split(net): def add_test_xward(net): _, b2, _ = add_grid_connection(net, zone="test_xward") - pz = 1.200 qz = 1.100 ps = 0.500 diff --git a/pandapower/test/loadflow/test_dist_slack.py b/pandapower/test/loadflow/test_dist_slack.py index 3ba2b3e5ae..a6b7ee235e 100644 --- a/pandapower/test/loadflow/test_dist_slack.py +++ b/pandapower/test/loadflow/test_dist_slack.py @@ -39,12 +39,15 @@ def small_example_grid(): create_load(net, 1, p_mw=100, q_mvar=100) - create_line_from_parameters(net, 0, 1, length_km=3, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, - max_i_ka=1) - create_line_from_parameters(net, 1, 2, length_km=2, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, - max_i_ka=1) - create_line_from_parameters(net, 2, 0, length_km=1, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, - max_i_ka=1) + create_line_from_parameters( + net, 0, 1, length_km=3, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, max_i_ka=1 + ) + create_line_from_parameters( + net, 1, 2, length_km=2, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, max_i_ka=1 + ) + create_line_from_parameters( + net, 2, 0, length_km=1, r_ohm_per_km=0.01, x_ohm_per_km=0.1, c_nf_per_km=0, max_i_ka=1 + ) return net @@ -61,7 +64,7 @@ def _get_xward_result(net): else: p_impedance = np.array([]) - for b, x_id in zip(net.xward.query("in_service").bus.values, net.xward.query("in_service").index.values): + for b, x_id in zip(net.xward[net.xward.in_service].bus, net.xward[net.xward.in_service].index.values): p_bus = ppc['bus'][net._pd2ppc_lookups["bus"][b], PD] p_shunt = ppc['bus'][net._pd2ppc_lookups["bus"][b], VM] ** 2 * net["xward"].at[x_id, "pz_mw"] internal_results = np.append(internal_results, p_shunt) @@ -96,18 +99,20 @@ def _get_injection_consumption(net): # active power consumption by the internal elements of xward is not adjusted by the distributed slack calculation # that is why we add the active power of the internal elements of the xward here consumed_p_mw = total_pl_mw + \ - net.load.query("in_service").p_mw.sum() - \ - net.sgen.query("in_service").p_mw.sum() + \ + net.load[net.load.in_service].p_mw.sum() - \ + net.sgen[net.load.in_service].p_mw.sum() + \ xward_internal.sum() - injected_p_mw = net.gen.query("in_service").p_mw.sum() + injected_p_mw = net.gen[net.gen.in_service].p_mw.sum() # we return the xward power separately because it is also already considered in the inputs and results - return injected_p_mw, consumed_p_mw, net.xward.query("in_service").ps_mw.sum() + return injected_p_mw, consumed_p_mw, net.xward[net.xward.in_service].ps_mw.sum() def _get_slack_weights(net): - slack_weights = np.r_[net.gen.query("in_service").slack_weight, - net.ext_grid.query("in_service").slack_weight, - net.xward.query("in_service").slack_weight] + slack_weights = np.r_[ + net.gen[net.gen.in_service].slack_weight if "slack_weight" in net.gen.columns else [], + net.ext_grid[net.ext_grid.in_service].slack_weight if "slack_weight" in net.ext_grid.columns else [], + net.xward[net.xward.in_service].slack_weight if "slack_weight" in net.xward.columns else [] + ] return slack_weights / sum(slack_weights) @@ -116,9 +121,9 @@ def _get_inputs_results(net): # that is why we only consider the active power consumption by the PQ load of the xward here xward_pq_res, _ = _get_xward_result(net) # xward is in the consumption reference system, but here the results are all assumed in the generation reference system - inputs = np.r_[net.gen.query("in_service").p_mw, - np.zeros(len(net.ext_grid.query("in_service"))), - -net.xward.query("in_service").ps_mw] + inputs = np.r_[net.gen[net.gen.in_service].p_mw, + np.zeros(len(net.ext_grid[net.ext_grid.in_service])), + -net.xward[net.xward.in_service].ps_mw] results = np.r_[net.res_gen[net.gen.in_service].p_mw, net.res_ext_grid[net.ext_grid.in_service].p_mw, -xward_pq_res] @@ -134,8 +139,11 @@ def assert_results_correct(net, tol=1e-8): # assert power balance is correct assert abs(result_p_mw.sum() - consumed_p_mw) < tol, "power balance is wrong" # assert results are according to the distributed slack formula - assert np.allclose(input_p_mw - (injected_p_mw - consumed_p_mw - consumed_xward_p_mw) * slack_weights, result_p_mw, - atol=tol, rtol=0), "distributed slack weights formula has a wrong result" + assert np.allclose( + input_p_mw - (injected_p_mw - consumed_p_mw - consumed_xward_p_mw) * slack_weights, result_p_mw, + atol=tol, + rtol=0 + ), "distributed slack weights formula has a wrong result" def check_xward_results(net, tol=1e-9): @@ -155,7 +163,7 @@ def run_and_assert_numba(net, **kwargs): def test_get_xward_result(): # here we test the helper function that calculates the internal and PQ load results separately - # it separates the results of other node ellments at the same bus, but only works for 1 xward at a bus + # it separates the results of other node elements at the same bus, but only works for 1 xward at a bus net = small_example_grid() create_xward(net, 2, 100, 0, 0, 0, 0.02, 0.2, 1) create_load(net, 2, 50, 0, 0, 0, 0.02, 0.2, 1) diff --git a/pandapower/test/loadflow/test_facts.py b/pandapower/test/loadflow/test_facts.py index e3340c62fc..eefd4152ba 100644 --- a/pandapower/test/loadflow/test_facts.py +++ b/pandapower/test/loadflow/test_facts.py @@ -3,9 +3,13 @@ import numpy as np import pytest -from pandapower.create import create_impedance, create_shunts, create_buses, create_gens, create_svc, create_tcsc, \ - create_bus, create_empty_network, create_line_from_parameters, create_load, create_ext_grid, \ - create_transformer_from_parameters, create_gen, create_ssc + +from pandapower.create import ( + create_impedance, create_shunts, create_buses, create_gens, create_svc, create_tcsc, create_bus, create_gen, + create_empty_network, create_line_from_parameters, create_load, create_ext_grid, create_transformer_from_parameters, + create_ssc +) +from pandapower.create._utils import add_column_to_df from pandapower.run import runpp from pandapower.test.consistency_checks import runpp_with_consistency_checks @@ -48,8 +52,9 @@ def _many_tcsc_test_net(): def compare_tcsc_impedance(net, net_ref, idx_tcsc, idx_impedance): backup_q = net_ref.res_bus.loc[net.ssc.bus.values, "q_mvar"].copy() - net_ref.res_bus.loc[net.ssc.bus.values, "q_mvar"] += net_ref.res_impedance.loc[ - net_ref.impedance.query("name=='ssc'").index, "q_from_mvar"].values + if "name" in net_ref.impedance.columns: + net_ref.res_bus.loc[net.ssc.bus.values, "q_mvar"] += net_ref.res_impedance.loc[ + net_ref.impedance.query("name=='ssc'").index, "q_from_mvar"].values bus_idx = net.bus.index.values for col in ("vm_pu", "va_degree", "p_mw", "q_mvar"): assert np.allclose(net.res_bus[col], net_ref.res_bus.loc[bus_idx, col], rtol=0, atol=1e-6) @@ -258,6 +263,7 @@ def test_svc_tcsc_case_study(): runpp(net_ref) compare_tcsc_impedance(net, net_ref, net.tcsc.index, net_ref.impedance.index) + add_column_to_df(net, "gen", "slack_weight") net.gen.slack_weight = 1 runpp(net, distributed_slack=True, init="dc") net_ref = copy_with_impedance(net) diff --git a/pandapower/test/loadflow/test_runpp.py b/pandapower/test/loadflow/test_runpp.py index 131d11000c..dc98055200 100644 --- a/pandapower/test/loadflow/test_runpp.py +++ b/pandapower/test/loadflow/test_runpp.py @@ -14,6 +14,7 @@ from pandapower import pp_dir from pandapower.auxiliary import _check_connectivity, _add_ppc_options, lightsim2grid_available +from pandapower.create._utils import add_column_to_df from pandapower.create import create_bus, create_empty_network, create_ext_grid, create_dcline, create_load, \ create_sgen, create_switch, create_transformer, create_xward, create_transformer3w, create_gen, create_shunt, \ create_line_from_parameters, create_line, create_impedance, create_storage, create_buses, \ @@ -173,14 +174,13 @@ def test_runpp_init_auxiliary_buses(): atol=2) -def test_result_iter(): - for net in result_test_network_generator(): - try: - runpp_with_consistency_checks(net, enforce_q_lims=True) - except (AssertionError): - raise UserWarning("Consistency Error after adding %s" % net.last_added_case) - except(LoadflowNotConverged): - raise UserWarning("Power flow did not converge after adding %s" % net.last_added_case) +def test_result_iter(result_test_networks): + try: + runpp_with_consistency_checks(result_test_networks, enforce_q_lims=True) + except (AssertionError): + raise UserWarning(f"Consistency Error after adding {result_test_networks.last_added_case}") + except(LoadflowNotConverged): + raise UserWarning(f"Power flow did not converge after adding {result_test_networks.last_added_case}") @pytest.fixture @@ -405,22 +405,11 @@ def test_connectivity_check_island_with_one_pv_bus(): std_type="N2XS(FL)2Y 1x300 RM/35 64/110 kV", name="IsolatedLine") create_line(net, isolated_gen, isolated_bus1, length_km=1, std_type="N2XS(FL)2Y 1x300 RM/35 64/110 kV", name="IsolatedLineToGen") - # with pytest.warns(UserWarning): iso_buses, iso_p, iso_q, *_ = get_isolated(net) - # assert len(iso_buses) == 0 - # assert np.isclose(iso_p, 0) - # assert np.isclose(iso_q, 0) - # # create_load(net, isolated_bus1, p_mw=0.200., q_mvar=0.020) # create_sgen(net, isolated_bus2, p_mw=0.0150., q_mvar=-0.010) - # - # iso_buses, iso_p, iso_q = get_isolated(net) - # assert len(iso_buses) == 0 - # assert np.isclose(iso_p, 0) - # assert np.isclose(iso_q, 0) - # with pytest.warns(UserWarning): runpp_with_consistency_checks(net, check_connectivity=True) @@ -1408,6 +1397,8 @@ def test_tap_dependent_impedance(): 'angle_deg': [0, 0, 0, 0, 0], 'vk_percent': [5.5, 5.8, 6, 6.2, 6.5], 'vkr_percent': [1.4, 1.42, 1.44, 1.46, 1.48], 'vk_hv_percent': np.nan, 'vkr_hv_percent': np.nan, 'vk_mv_percent': np.nan, 'vkr_mv_percent': np.nan, 'vk_lv_percent': np.nan, 'vkr_lv_percent': np.nan}) + add_column_to_df(net, "trafo", "id_characteristic_table") + add_column_to_df(net, "trafo", 'tap_dependency_table') net.trafo.at[0, 'id_characteristic_table'] = 0 net.trafo.at[0, 'tap_dependency_table'] = True net.trafo.at[1, 'tap_dependency_table'] = False @@ -1419,6 +1410,8 @@ def test_tap_dependent_impedance(): 'vkr_mv_percent': [0.3, 0.3, 0.3, 0.3, 0.3], 'vk_lv_percent': [1, 1, 1, 1, 1], 'vkr_lv_percent': [0.3, 0.3, 0.3, 0.3, 0.3]}) net["trafo_characteristic_table"] = pd.concat([net["trafo_characteristic_table"], new_rows], ignore_index=True) + add_column_to_df(net, "trafo3w", "id_characteristic_table") + add_column_to_df(net, "trafo3w", 'tap_dependency_table') net.trafo3w.at[0, 'id_characteristic_table'] = 1 net.trafo3w.at[0, 'tap_dependency_table'] = True @@ -1467,6 +1460,8 @@ def test_tap_table_order(): 'vk_hv_percent': [0.95, 0.98, 1, 1.02, 1.05], 'vkr_hv_percent': [0.3, 0.3, 0.3, 0.3, 0.3], 'vk_mv_percent': [1, 1, 1, 1, 1], 'vkr_mv_percent': [0.3, 0.3, 0.3, 0.3, 0.3], 'vk_lv_percent': [1, 1, 1, 1, 1], 'vkr_lv_percent': [0.3, 0.3, 0.3, 0.3, 0.3]}) + add_column_to_df(net, "trafo3w", "id_characteristic_table") + add_column_to_df(net, "trafo3w", 'tap_dependency_table') net.trafo3w.at[0, 'id_characteristic_table'] = 0 net.trafo3w.at[0, 'tap_dependency_table'] = True @@ -1477,6 +1472,8 @@ def test_tap_table_order(): 'vkr_percent': [1.4, 1.42, 1.44, 1.46, 1.48, 1.4, 1.42, 1.44, 1.46, 1.48], 'vk_hv_percent': np.nan, 'vkr_hv_percent': np.nan, 'vk_mv_percent': np.nan, 'vkr_mv_percent': np.nan, 'vk_lv_percent': np.nan, 'vkr_lv_percent': np.nan}) net["trafo_characteristic_table"] = pd.concat([net["trafo_characteristic_table"], new_rows], ignore_index=True) + add_column_to_df(net, "trafo", "id_characteristic_table") + add_column_to_df(net, "trafo", 'tap_dependency_table') net.trafo.at[0, 'id_characteristic_table'] = 2 net.trafo.at[1, 'id_characteristic_table'] = 1 net.trafo.at[0, 'tap_dependency_table'] = True @@ -1679,7 +1676,8 @@ def test_q_capability_curve(): 0, -265.01001, -134.00999, -0.01000], 'q_max_mvar': [0.01000, 134.00999, 228.00999, 257.01001, 261.01001, 261.01001, 261.01001, 257.01001, 30, 40, 134.0099, 0.01]}) - + add_column_to_df(net, "gen", "id_q_capability_characteristic") + add_column_to_df(net, "gen", "reactive_capability_curve") net.gen.at[0, "id_q_capability_characteristic"] = 0 net.gen['curve_style'] = "straightLineYValues" @@ -1728,7 +1726,8 @@ def test_q_capability_curve_for_sgen(): -265.01001, -134.00999, -0.01000], 'q_max_mvar': [0.01000, 134.00999, 228.00999, 257.01001, 261.01001, 261.01001, 261.01001, 257.01001, 218.0099945068, 134.0099, 0.01]}) - + add_column_to_df(net, "sgen", "id_q_capability_characteristic") + add_column_to_df(net, "sgen", "reactive_capability_curve") net.sgen.at[0, "id_q_capability_characteristic"] = 0 net.sgen['curve_style'] = "straightLineYValues" create_q_capability_characteristics_object(net) diff --git a/pandapower/test/loadflow/test_runpp_pgm.py b/pandapower/test/loadflow/test_runpp_pgm.py index c900705434..39169275ec 100644 --- a/pandapower/test/loadflow/test_runpp_pgm.py +++ b/pandapower/test/loadflow/test_runpp_pgm.py @@ -3,8 +3,10 @@ import pytest -from pandapower.create import create_empty_network, create_bus, create_ext_grid, create_load, create_switch, \ - create_sgen, create_line +from pandapower.create._utils import add_column_to_df +from pandapower.create import ( + create_empty_network, create_bus, create_ext_grid, create_load, create_switch, create_sgen, create_line +) from pandapower.run import runpp_pgm from pandapower.test.consistency_checks import runpp_pgm_with_consistency_checks, runpp_pgm_3ph_with_consistency_checks @@ -26,6 +28,8 @@ def test_minimal_net_pgm(consistency_fn): consistency_fn(net) create_load(net, b, p_mw=0.1) + # FIXME: temporary skip for pgm converter due to pd.NA + pytest.skip("PGM's _get_pp_attr has an error when handling pandapower 4 networks. (pd.NA dtype support missing)") consistency_fn(net) b2 = create_bus(net, 110) diff --git a/pandapower/test/loadflow/test_scenarios.py b/pandapower/test/loadflow/test_scenarios.py index b620f06785..2870d2ef65 100644 --- a/pandapower/test/loadflow/test_scenarios.py +++ b/pandapower/test/loadflow/test_scenarios.py @@ -156,7 +156,7 @@ def test_ext_grid_gen_order_in_ppc(): net = create_empty_network() for b in range(6): - create_bus(net, vn_kv=1., name=b) + create_bus(net, vn_kv=1., name=str(b)) for l_bus in range(0, 5, 2): create_line(net, from_bus=l_bus, to_bus=l_bus + 1, length_km=1, diff --git a/pandapower/test/loadflow/test_tdpf.py b/pandapower/test/loadflow/test_tdpf.py index f3925377ae..c3a3caf444 100644 --- a/pandapower/test/loadflow/test_tdpf.py +++ b/pandapower/test/loadflow/test_tdpf.py @@ -10,8 +10,10 @@ import copy from pandapower import pp_dir -from pandapower.create import create_empty_network, create_bus, create_line, create_load, create_ext_grid, \ - create_buses, create_sgen, create_gen, create_gens, create_line_from_parameters +from pandapower.create import ( + create_empty_network, create_bus, create_line, create_load, create_ext_grid, create_buses, create_sgen, create_gen, + create_gens, create_line_from_parameters +) from pandapower.networks.power_system_test_cases import case9, case30 from pandapower.pf.create_jacobian_tdpf import calc_r_theta_from_t_rise, calc_i_square_p_loss, calc_g_b, \ calc_a0_a1_a2_tau, calc_T_ngoko, calc_r_theta, calc_T_frank @@ -145,7 +147,9 @@ def simple_test_grid(load_scaling=1., sgen_scaling=1., with_gen=False, distribut create_sgen(net, 4, 300, scaling=sgen_scaling, name="G5") if distributed_slack: - net["gen" if with_gen else "sgen"].at[idx, 'slack_weight'] = 1 + if with_gen: # distributed slack is currently not supported for sgen. + net["gen"]["slack_weight"] = 0.0 + net["gen"].at[idx, 'slack_weight'] = 1 set_user_pf_options(net, distributed_slack=True) net.sn_mva = 1000 # otherwise numerical issues diff --git a/pandapower/test/network_schema/__init__.py b/pandapower/test/network_schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/test/network_schema/elements/__init__.py b/pandapower/test/network_schema/elements/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/test/network_schema/elements/helper.py b/pandapower/test/network_schema/elements/helper.py new file mode 100644 index 0000000000..cd1721be6b --- /dev/null +++ b/pandapower/test/network_schema/elements/helper.py @@ -0,0 +1,69 @@ +import numpy as np +import pandas as pd + +# Boolean types +bools = [True, False, np.bool_(True), np.bool_(False)] +# String types +strings = [ + "True", + "False", + "true", + "false", + "1", + "0", + "yes", + "no", + "not a number", + "", + " ", +] +others = [ + # None and NaN variants + None, + pd.NaT, + # Collections + {}, + {"value": True}, + # Objects + object(), + type, + lambda x: x, + # Complex numbers + complex(1, 0), + 1 + 0j, +] +# Numeric types +# ints +zero_int = [np.int64(0), 0] +positiv_ints = [1, 42, np.int64(1)] +positiv_ints_plus_zero = [*positiv_ints, *zero_int] +negativ_ints = [-1, -42, np.int64(-1)] +negativ_ints_plus_zero = [*negativ_ints, *zero_int] +all_allowed_ints = [*zero_int, *positiv_ints, *negativ_ints] +not_allowed_ints = [np.int8(1), np.int16(1), np.int32(1), np.uint8(1), np.uint16(1), np.uint32(1)] +all_ints = [*all_allowed_ints, *not_allowed_ints] +# floats +zero_float: list[float | np.float64] = [0.0, np.float64(0.0)] +positiv_floats: list[float | np.float64] = [1.0, np.float64(1.0)] +positiv_floats_plus_zero: list[float | np.float64] = [*positiv_floats, *zero_float] +negativ_floats: list[float | np.float64] = [-1.0, np.float64(-1.0)] +negativ_floats_plus_zero: list[float | np.float64] = [*negativ_floats, *zero_float] +all_allowed_floats: list[float | np.float64] = [*zero_float, *positiv_floats, *negativ_floats] +not_allowed_floats: list[float | np.float64 | np.float32 | np.float16] = [ + np.float32(1.0), + np.float16(1.0), + float("inf"), + float("-inf"), +] +all_floats: list[float | np.float64 | np.float32 | np.float16] = [*all_allowed_floats, *not_allowed_floats] + +not_boolean_list = [*others, *strings, *all_ints, *all_floats] +not_floats_list = [*others, *strings, *all_ints, *bools] +not_strings_list = [*others, *all_ints, *bools, *all_floats] +not_ints_list = [*others, *strings, *all_floats, *bools] + +# percentages and ratios +ratio_valid = [0.0, 0.5, 1.0] +ratio_invalid = [-0.1, 1.1] +percent_valid = [0.0, 50.0, 100.0] +percent_invalid = [-0.1, 100.1] diff --git a/pandapower/test/network_schema/elements/test_pandera_asymetric_load_elements.py b/pandapower/test/network_schema/elements/test_pandera_asymetric_load_elements.py new file mode 100644 index 0000000000..4a6da4843d --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_asymetric_load_elements.py @@ -0,0 +1,287 @@ +# test_asymmetric_load_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_asymmetric_load +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestAsymmetricLoadRequiredFields: + """Tests for required asymmetric_load fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_a_mw"], positiv_floats_plus_zero), + itertools.product(["p_b_mw"], positiv_floats_plus_zero), + itertools.product(["p_c_mw"], positiv_floats_plus_zero), + itertools.product(["q_a_mvar"], all_allowed_floats), + itertools.product(["q_b_mvar"], all_allowed_floats), + itertools.product(["q_c_mvar"], all_allowed_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + itertools.product(["type"], ["wye", "delta"]), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_bus(net, 0.4, index=42) + + create_asymmetric_load( + net, + bus=0, + p_a_mw=1.0, + p_b_mw=1.0, + p_c_mw=1.0, + q_a_mvar=0.5, + q_b_mvar=0.5, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + type="wye", + name="test", + sn_mva=10.0, + ) + + net.asymmetric_load[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_a_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["p_b_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["p_c_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["q_a_mvar"], not_floats_list), + itertools.product(["q_b_mvar"], not_floats_list), + itertools.product(["q_c_mvar"], not_floats_list), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + itertools.product(["type"], [*strings, *not_strings_list]), # invalid strings + non-strings + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=0, + p_a_mw=1.0, + p_b_mw=1.0, + p_c_mw=1.0, + q_a_mvar=0.5, + q_b_mvar=0.5, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + type="wye", + name="test", + sn_mva=10.0, + ) + + net.asymmetric_load[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricLoadOptionalFields: + """Tests for optional asymmetric_load fields""" + + def test_all_optional_fields_valid(self): + """Test: asymmetric_load with every optional field is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=b0, + p_a_mw=10.0, + p_b_mw=12.0, + p_c_mw=11.0, + q_a_mvar=5.0, + q_b_mvar=6.0, + q_c_mvar=4.0, + scaling=1.0, + in_service=True, + type="wye", + name="lorem ipsum", + sn_mva=25.0, + ) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: asymmetric_load with optional fields including nulls is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=b0, + p_a_mw=10.0, + p_b_mw=10.0, + p_c_mw=10.0, + q_a_mvar=5.0, + q_b_mvar=5.0, + q_c_mvar=5.0, + scaling=1.0, + in_service=True, + type="delta", + name="lorem ipsum", + ) + create_asymmetric_load( + net, + bus=b0, + p_a_mw=8.0, + p_b_mw=9.0, + p_c_mw=7.5, + q_a_mvar=3.0, + q_b_mvar=3.5, + q_c_mvar=2.5, + scaling=1.0, + in_service=False, + type="wye", + sn_mva=15.0, + ) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["sn_mva"], positiv_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=b0, + p_a_mw=10.0, + p_b_mw=10.0, + p_c_mw=10.0, + q_a_mvar=5.0, + q_b_mvar=5.0, + q_c_mvar=5.0, + scaling=1.0, + in_service=True, + type="wye", + name="initial", + sn_mva=20.0, + ) + + if parameter == "name": + net.asymmetric_load[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.asymmetric_load[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=b0, + p_a_mw=1.0, + p_b_mw=1.0, + p_c_mw=1.0, + q_a_mvar=0.2, + q_b_mvar=0.2, + q_c_mvar=0.2, + scaling=1.0, + in_service=True, + type="wye", + ) + + net.asymmetric_load[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricLoadForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_load( + net, + bus=b0, + p_a_mw=1.0, + p_b_mw=1.0, + p_c_mw=1.0, + q_a_mvar=0.5, + q_b_mvar=0.5, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + type="delta", + ) + + # Set to a non-existent bus index + net.asymmetric_load["bus"] = 9999 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricLoadResults: + """Tests for asymmetric_load results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_asymmetric_load_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_asymmetric_load_3ph_results(self): + """Test: 3-phase results contain valid values per phase""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_asymetric_sgen_elements.py b/pandapower/test/network_schema/elements/test_pandera_asymetric_sgen_elements.py new file mode 100644 index 0000000000..9773956d29 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_asymetric_sgen_elements.py @@ -0,0 +1,301 @@ +# test_asymmetric_sgen.py + +import itertools +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_asymmetric_sgen +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestAsymmetricSgenRequiredFields: + """Tests for required asymmetric_sgen fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_a_mw"], negativ_floats_plus_zero), + itertools.product(["p_b_mw"], negativ_floats_plus_zero), + itertools.product(["p_c_mw"], negativ_floats_plus_zero), + itertools.product(["q_a_mvar"], all_allowed_floats), + itertools.product(["q_b_mvar"], all_allowed_floats), + itertools.product(["q_c_mvar"], all_allowed_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + itertools.product(["current_source"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_asymmetric_sgen( + net, + bus=0, + p_a_mw=-1.0, + q_a_mvar=0.5, + p_b_mw=-1.0, + q_b_mvar=0.5, + p_c_mw=-1.0, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + current_source=False, + type="PV", + name="test", + sn_mva=10.0, + ) + net.asymmetric_sgen[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_a_mw"], [*positiv_floats, *not_floats_list]), + itertools.product(["p_b_mw"], [*positiv_floats, *not_floats_list]), + itertools.product(["p_c_mw"], [*positiv_floats, *not_floats_list]), + itertools.product(["q_a_mvar"], not_floats_list), + itertools.product(["q_b_mvar"], not_floats_list), + itertools.product(["q_c_mvar"], not_floats_list), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + itertools.product(["current_source"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + + create_asymmetric_sgen( + net, + bus=0, + p_a_mw=-1.0, + q_a_mvar=0.5, + p_b_mw=-1.0, + q_b_mvar=0.5, + p_c_mw=-1.0, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + current_source=False, + type="PV", + name="test", + sn_mva=10.0, + ) + net.asymmetric_sgen[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricSgenOptionalFields: + """Tests for optional asymmetric_sgen fields""" + + def test_all_optional_fields_valid(self): + """Test: asymmetric_sgen with every optional field is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-10.0, + q_a_mvar=5.0, + p_b_mw=-12.0, + q_b_mvar=6.0, + p_c_mw=-11.0, + q_c_mvar=4.0, + scaling=1.0, + in_service=True, + current_source=True, + type="WP", + name="lorem ipsum", + sn_mva=25.0, + ) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: asymmetric_sgen with optional fields including nulls is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-10.0, + q_a_mvar=5.0, + p_b_mw=-10.0, + q_b_mvar=5.0, + p_c_mw=-10.0, + q_c_mvar=5.0, + scaling=1.0, + in_service=True, + current_source=False, + name="lorem ipsum", + ) + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-8.0, + q_a_mvar=3.0, + p_b_mw=-9.0, + q_b_mvar=3.5, + p_c_mw=-7.5, + q_c_mvar=2.5, + scaling=1.0, + in_service=False, + current_source=True, + type="CHP", + ) + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-8.0, + q_a_mvar=3.0, + p_b_mw=-9.0, + q_b_mvar=3.5, + p_c_mw=-7.5, + q_c_mvar=2.5, + scaling=1.0, + in_service=False, + current_source=True, + sn_mva=15.0, + ) + net.asymmetric_sgen["type"].at[0] = None + net.asymmetric_sgen["type"].at[2] = None + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + # itertools.product(["type"], [pd.NA, "PV", "WP", "CHP"]), + itertools.product(["sn_mva"], positiv_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-10.0, + q_a_mvar=5.0, + p_b_mw=-10.0, + q_b_mvar=5.0, + p_c_mw=-10.0, + q_c_mvar=5.0, + scaling=1.0, + in_service=True, + current_source=False, + **{parameter: valid_value}, + ) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], [*strings, *not_strings_list]), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-1.0, + q_a_mvar=0.2, + p_b_mw=-1.0, + q_b_mvar=0.2, + p_c_mw=-1.0, + q_c_mvar=0.2, + scaling=1.0, + in_service=True, + current_source=True, + ) + net.asymmetric_sgen[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricSgenForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_asymmetric_sgen( + net, + bus=b0, + p_a_mw=-1.0, + q_a_mvar=0.5, + p_b_mw=-1.0, + q_b_mvar=0.5, + p_c_mw=-1.0, + q_c_mvar=0.5, + scaling=1.0, + in_service=True, + current_source=False, + type="PV", + ) + + net.asymmetric_sgen["bus"] = 9999 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestAsymmetricSgenResults: + """Tests for asymmetric_sgen results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_asymmetric_sgen_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_asymmetric_sgen_3ph_results(self): + """Test: 3-phase results contain valid values per phase""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_bus_dc_elements.py b/pandapower/test/network_schema/elements/test_pandera_bus_dc_elements.py new file mode 100644 index 0000000000..b75b9e09c5 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_bus_dc_elements.py @@ -0,0 +1,138 @@ +import itertools +from cmath import isnan + +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus_dc +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + negativ_floats, + positiv_floats, + not_allowed_floats, + zero_float, +) + + +class TestBusDCRequiredFields: + """Tests for required dc bus fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["vn_kv"], positiv_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + net = create_empty_network() + + # A minimal valid bus_dc + create_bus_dc(net, vn_kv=1.0, in_service=True) + + # Modify the tested parameter + net.bus_dc.at[0, parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["vn_kv"], [*not_floats_list, *negativ_floats, *zero_float]), + itertools.product(["in_service"], [*not_boolean_list]), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus_dc(net, vn_kv=1.0, in_service=True) + + net.bus_dc[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestBusDCOptionalFields: + """Tests for optional dc bus fields""" + + def test_bus_dc_with_optional_fields(self): + net = create_empty_network() + create_bus_dc( + net, + vn_kv=1.0, + in_service=True, + name="my_dc_bus", + type="n", + zone="europe", + geo="POINT(0 0)", + max_vm_pu=1.1, + min_vm_pu=0.9, + ) + validate_network(net) + + def test_bus_dc_with_optional_fields_including_nulls(self): + net = create_empty_network() + create_bus_dc(net, vn_kv=1.0, in_service=True, name="bye world") + create_bus_dc(net, vn_kv=1.0, in_service=True, type="b") + create_bus_dc(net, vn_kv=1.0, in_service=True, zone="somewhere") + create_bus_dc(net, vn_kv=1.0, in_service=True, geo=pd.NA) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + # itertools.product(["name", "type", "zone", "geo"], [pd.NA, *strings]), + itertools.product(["min_vm_pu", "max_vm_pu"], [float(np.nan), np.nan, *positiv_floats]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + create_bus_dc(net, vn_kv=1.0, in_service=True, **{parameter: valid_value}) + if parameter in "min_vm_pu" and not isnan(valid_value): + net.bus_dc.at[0, "max_vm_pu"] = 2.0 + if parameter in "max_vm_pu" and not isnan(valid_value): + net.bus_dc.at[0, "min_vm_pu"] = 0.0 + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name", "type", "zone", "geo"], [*not_strings_list, np.nan]), + itertools.product(["min_vm_pu", "max_vm_pu"], [*not_floats_list, *not_allowed_floats]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus_dc(net, vn_kv=1.0, in_service=True) + net.bus_dc[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestBusDCResults: + """Tests for bus_dc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_bus_dc_voltage_results(self): + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_bus_dc_power_results(self): + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_bus_elements.py b/pandapower/test/network_schema/elements/test_pandera_bus_elements.py new file mode 100644 index 0000000000..25b2b5d58e --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_bus_elements.py @@ -0,0 +1,142 @@ +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.network_schema.tools.helper import get_dtypes +from pandapower.network_schema.bus import bus_schema +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_allowed_floats, + not_boolean_list, + negativ_floats, + positiv_floats, +) + + +class TestBusRequiredFields: + """Tests for required bus fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["vn_kv"], positiv_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: Invalid required values are rejected""" + net = create_empty_network() + kwargs = {parameter: valid_value} + vn_kv = kwargs.pop("vn_kv", 0.4) + create_bus(net, vn_kv, **kwargs) + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], [float(np.nan), *not_strings_list]), + itertools.product(["vn_kv"], [float(np.nan), pd.NA, *not_floats_list, *negativ_floats]), + itertools.product(["in_service"], [float(np.nan), pd.NA, *not_boolean_list]), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: Invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + net.bus[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestBusOptionalFields: + """Tests for optional bus fields""" + + def test_bus_with_optional_fields(self): + """Test: Bus with every optional fields is valid""" + net = create_empty_network() + create_bus(net, vn_kv=0.4, zone="everywhere", max_vm_pu=1.1, min_vm_pu=0.9, geodata=(0, 0), type="b") + validate_network(net) + + def test_buses_with_optional_fields_including_nullvalues(self): + """Test: Buses with some optional fields is valid""" + net = create_empty_network() + create_bus(net, 0.4, zone="nowhere") + create_bus(net, 0.4, max_vm_pu=1) + create_bus(net, 0.4, min_vm_pu=0.9) + create_bus(net, 0.4, geodata=(1, 2)) + create_bus(net, 0.4, type="x") + + validate_network(net) + + def test_valid_type_values(self): + """Test: Valid 'type' values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + net.bus["type"].at[0] = "x" + net.bus["type"].at[1] = pd.NA + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["min_vm_pu", "max_vm_pu"], [float(np.nan), np.nan, *positiv_floats]), + itertools.product(["type", "zone", "geo"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + create_bus(net, 0.4, **{parameter: valid_value}) + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["min_vm_pu", "max_vm_pu"], [*not_floats_list, *not_allowed_floats]), + itertools.product(["type", "zone", "geo"], [np.nan, float(np.nan), *not_strings_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: Invalid optional values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + net.bus[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestBusResults: + """Tests for bus results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_bus_voltage_results(self): + """Test: Voltage results are within valid range""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_bus_power_results(self): + """Test: Power results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_dcline_elements.py b/pandapower/test/network_schema/elements/test_pandera_dcline_elements.py new file mode 100644 index 0000000000..2f3f92f39a --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_dcline_elements.py @@ -0,0 +1,264 @@ +# test_dcline.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_dcline +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestDclineRequiredFields: + """Tests for required dcline fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["from_bus"], positiv_ints_plus_zero), + itertools.product(["to_bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], all_allowed_floats), + itertools.product(["loss_percent"], positiv_floats_plus_zero), + itertools.product(["loss_mw"], positiv_floats_plus_zero), + itertools.product(["vm_from_pu"], positiv_floats), + itertools.product(["vm_to_pu"], positiv_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_bus(net, 0.4, index=42) + + create_dcline( + net, + from_bus=0, + to_bus=1, + p_mw=0.0, + loss_percent=0.0, + loss_mw=0.0, + vm_from_pu=1.0, + vm_to_pu=1.0, + in_service=True, + ) + net.dcline[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["from_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["to_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], not_floats_list), + itertools.product(["loss_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["loss_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["vm_from_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vm_to_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_dcline( + net, + from_bus=0, + to_bus=1, + p_mw=0.0, + loss_percent=0.0, + loss_mw=0.0, + vm_from_pu=1.0, + vm_to_pu=1.0, + in_service=True, + ) + net.dcline[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestDclineOptionalFields: + """Tests for optional dcline fields""" + + def test_all_optional_fields_valid(self): + """Test: dcline with every optional field is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_dcline( + net, + from_bus=b0, + to_bus=b1, + p_mw=10.0, + loss_percent=1.0, + loss_mw=0.1, + vm_from_pu=1.02, + vm_to_pu=1.01, + in_service=True, + name="lorem ipsum", + max_p_mw=20.0, + min_p_mw=0.0, + min_q_from_mvar=-50.0, + max_q_from_mvar=50.0, + min_q_to_mvar=-40.0, + max_q_to_mvar=40.0, + ) + validate_network(net) + + # TODO failing on dependending columns + def test_optional_fields_with_nulls(self): + """Test: dcline with optional fields including nulls is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_dcline( + net, + from_bus=b0, + to_bus=b1, + p_mw=10.0, + loss_percent=1.0, + loss_mw=0.1, + vm_from_pu=1.02, + vm_to_pu=1.01, + in_service=True, + ) + create_dcline( + net, + from_bus=b0, + to_bus=b1, + p_mw=10.0, + loss_percent=1.0, + loss_mw=0.1, + vm_from_pu=1.02, + vm_to_pu=1.01, + in_service=True, + name="lorem ipsum", + ) + create_dcline( + net, + from_bus=b0, + to_bus=b1, + p_mw=10.0, + loss_percent=1.0, + loss_mw=0.1, + vm_from_pu=1.02, + vm_to_pu=1.01, + in_service=True, + max_p_mw=20.0, + min_p_mw=0.0, + min_q_from_mvar=-50.0, + max_q_from_mvar=50.0, + min_q_to_mvar=-40.0, + max_q_to_mvar=40.0, + ) + validate_network(net) + + # TODO failing on dependending columns + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["min_q_from_mvar"], all_allowed_floats), + itertools.product(["max_q_from_mvar"], all_allowed_floats), + itertools.product(["min_q_to_mvar"], all_allowed_floats), + itertools.product(["max_q_to_mvar"], all_allowed_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_dcline( + net, + from_bus=b0, + to_bus=b1, + p_mw=10.0, + loss_percent=1.0, + loss_mw=0.1, + vm_from_pu=1.02, + vm_to_pu=1.01, + in_service=True, + name="lorem ipsum", + max_p_mw=20.0, + min_p_mw=0.0, + min_q_from_mvar=-50.0, + max_q_from_mvar=50.0, + min_q_to_mvar=-40.0, + max_q_to_mvar=40.0, + ) + if parameter == "name": + net.dcline[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.dcline[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["min_q_from_mvar"], not_floats_list), + itertools.product(["max_q_from_mvar"], not_floats_list), + itertools.product(["min_q_to_mvar"], not_floats_list), + itertools.product(["max_q_to_mvar"], not_floats_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_dcline( + net, from_bus=b0, to_bus=b1, p_mw=1.0, loss_percent=0.0, loss_mw=0.0, vm_from_pu=1.0, vm_to_pu=1.0 + ) + + net.dcline[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestDclineResults: + """Tests for dcline results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_dcline_voltage_results(self): + """Test: Voltage results are within valid range""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_dcline_power_results(self): + """Test: Power results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_ext_grid_elements.py b/pandapower/test/network_schema/elements/test_pandera_ext_grid_elements.py new file mode 100644 index 0000000000..821b635de5 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_ext_grid_elements.py @@ -0,0 +1,306 @@ +# test_ext_grid.py + +import itertools +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_ext_grid +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestExtGridRequiredFields: + """Tests for required ext_grid fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["vm_pu"], positiv_floats), + itertools.product(["va_degree"], all_allowed_floats), + itertools.product(["slack_weight"], all_allowed_floats), + itertools.product(["in_service"], bools), + itertools.product(["controllable"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_ext_grid(net, bus=0, vm_pu=1.0, va_degree=0.0, in_service=True) + + net.ext_grid[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["vm_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["va_degree"], not_floats_list), + itertools.product(["slack_weight"], not_floats_list), + itertools.product(["in_service"], not_boolean_list), + itertools.product(["controllable"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + + create_ext_grid(net, bus=0, vm_pu=1.0, va_degree=0.0, in_service=True) + + net.ext_grid[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestExtGridOptionalFields: + """Tests for optional ext_grid fields, including group dependencies (opf, sc, 3ph)""" + + def test_all_optional_fields_valid(self): + """Test: ext_grid with every optional field is valid and dependencies satisfied""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ext_grid( + net, + bus=b0, + vm_pu=1.02, + va_degree=0.0, + in_service=True, + # OPF group + max_p_mw=100.0, + min_p_mw=-100.0, + max_q_mvar=50.0, + min_q_mvar=-50.0, + # SC group + s_sc_max_mva=1000.0, + s_sc_min_mva=500.0, + # 3PH group (also part of SC group) + rx_max=0.5, + rx_min=0.1, + r0x0_max=0.2, + x0x_max=3.0, + name="ext grid 1", + ) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: ext_grid with optional fields including nulls, with dependencies respected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Row 1: OPF present, SC/3PH absent + create_ext_grid( + net, + bus=b0, + vm_pu=1.01, + va_degree=0.0, + in_service=True, + max_p_mw=80.0, + min_p_mw=-80.0, + max_q_mvar=40.0, + min_q_mvar=-40.0, + name="alpha", + ) + # Row 2: SC + 3PH present, OPF absent + create_ext_grid( + net, + bus=b0, + vm_pu=1.03, + va_degree=0.0, + in_service=True, + s_sc_max_mva=1100.0, + s_sc_min_mva=600.0, + rx_max=0.6, + rx_min=0.2, + r0x0_max=0.3, + x0x_max=2.5, + name=None, + ) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["max_q_mvar"], all_allowed_floats), + itertools.product(["min_q_mvar"], all_allowed_floats), + itertools.product(["s_sc_max_mva"], positiv_floats), + itertools.product(["s_sc_min_mva"], positiv_floats), + itertools.product(["rx_max"], positiv_floats_plus_zero), + itertools.product(["rx_min"], positiv_floats_plus_zero), + itertools.product(["r0x0_max"], positiv_floats_plus_zero), + itertools.product(["x0x_max"], positiv_floats_plus_zero), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ext_grid( + net, + bus=b0, + vm_pu=1.02, + va_degree=0.0, + in_service=True, + # OPF group + max_p_mw=100.0, + min_p_mw=-100.0, + max_q_mvar=50.0, + min_q_mvar=-50.0, + # SC group + s_sc_max_mva=1000.0, + s_sc_min_mva=500.0, + # 3PH group (also part of SC group) + rx_max=0.5, + rx_min=0.1, + r0x0_max=0.2, + x0x_max=3.0, + name="ext grid 1", + ) + net.ext_grid[parameter] = valid_value + net.ext_grid["name"] = net.ext_grid["name"].astype("string") + validate_network(net) + + def test_opf_group_partial_missing_invalid(self): + """Test: OPF group must be complete if any OPF value is set""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_ext_grid(net, bus=b0, vm_pu=1.0, va_degree=0.0, in_service=True) + # Set only one OPF column -> should fail by group dependency + net.ext_grid["max_p_mw"] = 100.0 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_sc_group_partial_missing_invalid(self): + """Test: SC group must be complete if any SC value is set""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ext_grid(net, bus=b0, vm_pu=1.0, va_degree=0.0, in_service=True) + # Set only s_sc_max_mva -> should fail by group dependency + net.ext_grid["s_sc_max_mva"] = 1000.0 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_3ph_group_partial_missing_invalid(self): + """Test: 3PH group must be complete if any 3PH value is set (and SC group too)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ext_grid(net, bus=b0, vm_pu=1.0, va_degree=0.0, in_service=True) + # Set only rx_max -> should fail by group dependency + net.ext_grid["rx_max"] = 0.4 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["max_q_mvar"], not_floats_list), + itertools.product(["min_q_mvar"], not_floats_list), + itertools.product(["s_sc_max_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["s_sc_min_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["rx_max"], [*negativ_floats, *not_floats_list]), + itertools.product(["rx_min"], [*negativ_floats, *not_floats_list]), + itertools.product(["r0x0_max"], [*negativ_floats, *not_floats_list]), + itertools.product(["x0x_max"], [*negativ_floats, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are not accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_ext_grid( + net, + bus=b0, + vm_pu=1.02, + va_degree=0.0, + in_service=True, + # OPF group + max_p_mw=100.0, + min_p_mw=-100.0, + max_q_mvar=50.0, + min_q_mvar=-50.0, + # SC group + s_sc_max_mva=1000.0, + s_sc_min_mva=500.0, + # 3PH group (also part of SC group) + rx_max=0.5, + rx_min=0.1, + r0x0_max=0.2, + x0x_max=3.0, + name="ext grid 1", + ) + net.ext_grid[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestExtGridForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ext_grid(net, bus=b0, vm_pu=1.0, va_degree=0.0, in_service=True) + + net.ext_grid["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestExtGridResults: + """Tests for ext_grid results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_ext_grid_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_ext_grid_3ph_results(self): + """Test: 3-phase results contain valid values per phase""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_gen_elements.py b/pandapower/test/network_schema/elements/test_pandera_gen_elements.py new file mode 100644 index 0000000000..3aa0bcdc5e --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_gen_elements.py @@ -0,0 +1,397 @@ +import itertools +import pandas as pd +import pandera as pa +import pytest +import numpy as np + +from pandapower.create import create_empty_network, create_bus, create_gen, create_asymmetric_sgen +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, + zero_float, + all_allowed_ints, +) + + +class TestGenRequiredFields: + """Tests for required asymmetric_sgen fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], all_allowed_floats), + itertools.product(["vm_pu"], positiv_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + itertools.product(["slack"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + ) + net.gen[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], not_floats_list), + itertools.product(["vm_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + itertools.product(["slack"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are not accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + ) + net.gen[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestGenOptionalFields: + """Tests for optional gen fields, including group dependencies""" + + def test_all_optional_fields_valid(self): + """Test: gen with every optional field is valid and dependencies satisfied""" + net = create_empty_network() + create_bus(net, 0.4) + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + # optional + name="test", + type="sync", + sn_mva=1.0, + max_q_mvar=1.1, + min_q_mvar=1.0, + max_p_mw=1.1, + min_p_mw=1.0, + vn_kv=1.0, + xdss_pu=1.0, + rdss_ohm=1.0, + cos_phi=1.0, + power_station_trafo=0, + id_q_capability_characteristic=0, + curve_style="straightLineYValues", + reactive_capability_curve=True, + slack_weight=1.0, + controllable=True, + pg_percent=1.0, + min_vm_pu=1.0, + max_vm_pu=1.1, + ) + validate_network(net) + + def test_optional_fields_opf_q_lim_enforced_with_nulls(self): + """Test: gen with optional fields including nulls, with dependencies respected""" + net = create_empty_network() + create_bus(net, 0.4) + + # Row 1: opf + q_lim_enforced present + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + # optional + max_q_mvar=1.1, + min_q_mvar=1.0, + max_p_mw=1.1, + min_p_mw=1.0, + controllable=True, + min_vm_pu=1.0, + max_vm_pu=1.1, + ) + + def test_optional_fields_qcc_with_nulls(self): + """Test: gen with optional fields including nulls, with dependencies respected""" + net = create_empty_network() + create_bus(net, 0.4) + # Row 2: qcc present + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + # optional + id_q_capability_characteristic=0, + curve_style="straightLineYValues", + reactive_capability_curve=True, + ) + + # def test_optional_fields_qcc_with_nulls(self): TODO scc commented out in gen.py + # """Test: gen with optional fields including nulls, with dependencies respected""" + # net = create_empty_network() + # create_bus(net, 0.4) + # Row 3: sc present + # create_gen( + # net, + # bus=0, + # p_mw=-1.0, + # vm_pu=0.5, + # scaling=1.0, + # in_service=True, + # slack=True, + # # optional + # vn_kv=1.0, + # xdss_pu=1.0, + # rdss_ohm=1.0, + # cos_phi=1.0, + # power_station_trafo=0, + # pg_percent=1.0, + # ) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["type"], strings), + itertools.product(["sn_mva"], positiv_floats), + itertools.product(["max_q_mvar"], all_allowed_floats), + itertools.product(["min_q_mvar"], all_allowed_floats), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["vn_kv"], all_allowed_floats), + itertools.product(["xdss_pu"], positiv_floats), + itertools.product(["rdss_ohm"], positiv_floats), + itertools.product(["cos_phi"], [*zero_float, 0.4]), + itertools.product(["in_service"], bools), + itertools.product(["id_q_capability_characteristic"], all_allowed_ints), + itertools.product(["curve_style"], ["straightLineYValues", "constantYValue"]), + itertools.product(["reactive_capability_curve"], bools), + itertools.product(["slack_weight"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["pg_percent"], all_allowed_floats), + itertools.product(["min_vm_pu"], positiv_floats), + itertools.product(["max_vm_pu"], positiv_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + # TODO: bool or pd.BooleanDtype + """Test: valid optional values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) + create_gen( + net, + bus=0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + # optional + name="test", + type="sync", + sn_mva=1.0, + max_q_mvar=2.0, + min_q_mvar=-2.0, + max_p_mw=2.0, + min_p_mw=-2.0, + vn_kv=1.0, + xdss_pu=1.0, + rdss_ohm=1.0, + cos_phi=1.0, + power_station_trafo=0, + id_q_capability_characteristic=0, + curve_style="straightLineYValues", + reactive_capability_curve=True, + slack_weight=1.0, + controllable=True, + pg_percent=1.0, + min_vm_pu=1.0, + max_vm_pu=1.1, + ) + net.gen[parameter] = valid_value + net.gen["controllable"] = net.gen["controllable"].astype(bool) + net.gen["name"] = net.gen["name"].astype("string") + net.gen["type"] = net.gen["type"].astype("string") + net.gen["curve_style"] = net.gen["curve_style"].astype("string") + + validate_network(net) + + def test_opf_group_partial_missing_invalid(self): + """Test: OPF group must be complete if any OPF value is set (gen)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + + # Set only one OPF column -> should fail due to group dependency + # Choose max_p_mw; other OPF columns are present as NaN in net.gen by default + net.gen["max_p_mw"] = 100.0 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_q_lim_enforced_group_partial_missing_invalid(self): + """Test: q_lim_enforced group (max_q_mvar/min_q_mvar) must be complete""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + + # Set only max_q_mvar -> should fail because min_q_mvar is missing/NaN + net.gen["max_q_mvar"] = 1.0 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_qcc_group_partial_missing_invalid(self): + """Test: QCC group must be complete if any value is set""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + + # Set only one QCC column at a time -> each should fail + # id_q_capability_characteristic only + net.gen["id_q_capability_characteristic"] = pd.Series([0], dtype="Int64") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Reset and set only curve_style + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + net.gen["curve_style"] = pd.Series(["straightLineYValues"], dtype="string") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Reset and set only reactive_capability_curve + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + net.gen["reactive_capability_curve"] = pd.Series([True], dtype="boolean") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["max_q_mvar"], not_floats_list), + itertools.product(["min_q_mvar"], not_floats_list), + itertools.product(["vn_kv"], not_floats_list), + itertools.product(["xdss_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["rdss_ohm"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["cos_phi"], [*negativ_floats, 1.1, *not_floats_list]), + itertools.product(["id_q_capability_characteristic"], not_ints_list), + itertools.product(["power_station_trafo"], not_ints_list), + itertools.product(["curve_style"], [*strings, *not_strings_list]), + itertools.product(["reactive_capability_curve"], not_boolean_list), + itertools.product(["slack_weight"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["pg_percent"], not_floats_list), + itertools.product(["min_vm_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["max_vm_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are not accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_gen( + net, + bus=b0, + p_mw=-1.0, + vm_pu=0.5, + scaling=1.0, + in_service=True, + slack=True, + # provide complete groups so only the target parameter triggers failure + max_q_mvar=1.1, + min_q_mvar=1.0, + max_p_mw=1.1, + min_p_mw=1.0, + controllable=True, + min_vm_pu=1.0, + max_vm_pu=1.1, + id_q_capability_characteristic=0, + curve_style="straightLineYValues", + reactive_capability_curve=True, + ) + net.gen[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestGenForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_gen(net, bus=b0, p_mw=-1.0, vm_pu=0.5, scaling=1.0, in_service=True, slack=True) + + net.gen["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestGenResults: + """Tests for gen results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_gen_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_impedance_elements.py b/pandapower/test/network_schema/elements/test_pandera_impedance_elements.py new file mode 100644 index 0000000000..f7178477f5 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_impedance_elements.py @@ -0,0 +1,279 @@ +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_impedance +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.test.network_schema.elements.helper import ( + strings, + all_allowed_floats, + not_floats_list, + not_strings_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats, + negativ_floats_plus_zero, + positiv_floats_plus_zero, + negativ_floats, + bools, +) + + +class TestImpedanceRequiredFields: + """Tests for required Impedance fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["from_bus"], positiv_ints_plus_zero), + itertools.product(["to_bus"], positiv_ints_plus_zero), + itertools.product(["rft_pu"], all_allowed_floats), + itertools.product(["xft_pu"], all_allowed_floats), + itertools.product(["rtf_pu"], all_allowed_floats), + itertools.product(["xtf_pu"], all_allowed_floats), + itertools.product(["gf_pu"], all_allowed_floats), + itertools.product(["bf_pu"], all_allowed_floats), + itertools.product(["gt_pu"], all_allowed_floats), + itertools.product(["bt_pu"], all_allowed_floats), + itertools.product(["sn_mva"], positiv_floats), # strictly > 0 + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + net = create_empty_network() + create_bus(net, 0.4, index=0) + create_bus(net, 0.4, index=1) + create_bus(net, 0.4, index=42) + # Provide all required fields explicitly + create_impedance( + net, + from_bus=0, + to_bus=1, + rft_pu=0.0, + xft_pu=0.0, + rtf_pu=0.0, + xtf_pu=0.0, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=100.0, + in_service=True, + ) + net.impedance[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["from_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["to_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["rft_pu"], not_floats_list), + itertools.product(["xft_pu"], not_floats_list), + itertools.product(["rtf_pu"], not_floats_list), + itertools.product(["xtf_pu"], not_floats_list), + itertools.product(["gf_pu"], not_floats_list), + itertools.product(["bf_pu"], not_floats_list), + itertools.product(["gt_pu"], not_floats_list), + itertools.product(["bt_pu"], not_floats_list), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), # <= 0 invalid + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_impedance( + net, + from_bus=0, + to_bus=1, + rft_pu=0.0, + xft_pu=0.0, + rtf_pu=0.0, + xtf_pu=0.0, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=100.0, + in_service=True, + ) + net.impedance[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestImpedanceOptionalFields: + """Tests for optional impedance fields""" + + def test_full_optional_fields_validation(self): + """Impedance with all optional fields is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + # Optional zero-sequence and name provided + create_impedance( + net, + from_bus=b0, + to_bus=b1, + rft_pu=0.01, + xft_pu=0.02, + rtf_pu=0.03, + xtf_pu=0.04, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=100.0, + in_service=False, + name="lorem ipsum", + rft0_pu=0.1, + xft0_pu=0.2, + rtf0_pu=0.3, + xtf0_pu=0.4, + gf0_pu=0.0, + bf0_pu=0.0, + gt0_pu=0.0, + bt0_pu=0.0, + ) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optional fields can be missing or null""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + # Only required fields + create_impedance( + net, + from_bus=b0, + to_bus=b1, + rft_pu=0.0, + xft_pu=0.0, + rtf_pu=0.0, + xtf_pu=0.0, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=50.0, + in_service=True, + ) + + # Set some optionals to NaN/None + for col in ["name", "rft0_pu", "xft0_pu", "rtf0_pu", "xtf0_pu", "gf0_pu", "bf0_pu", "gt0_pu", "bt0_pu"]: + # Ensure column exists and assign a null + if col not in net.impedance.columns: + net.impedance[col] = pd.Series([float(np.nan)], index=net.impedance.index) + net.impedance[col].iat[0] = pd.NA + + # Set name dtype properly + net.impedance["name"] = net.impedance["name"].astype(pd.StringDtype()) + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["rft0_pu"], positiv_floats), + itertools.product(["xft0_pu"], positiv_floats), + itertools.product(["rtf0_pu"], positiv_floats), + itertools.product(["xtf0_pu"], positiv_floats), + itertools.product(["gf0_pu"], [float(np.nan), *positiv_floats_plus_zero, *negativ_floats_plus_zero]), + itertools.product(["bf0_pu"], [float(np.nan), *positiv_floats_plus_zero, *negativ_floats_plus_zero]), + itertools.product(["gt0_pu"], [float(np.nan), *positiv_floats_plus_zero, *negativ_floats_plus_zero]), + itertools.product(["bt0_pu"], [float(np.nan), *positiv_floats_plus_zero, *negativ_floats_plus_zero]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_impedance( + net, + from_bus=b0, + to_bus=b1, + rft_pu=0.0, + xft_pu=0.0, + rtf_pu=0.0, + xtf_pu=0.0, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=10.0, + in_service=False, + ) + if parameter == "name": + net.impedance[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.impedance[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + # zero-sequence impedance parts must be > 0 if provided + itertools.product(["rft0_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["xft0_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["rtf0_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["xtf0_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + # zero-sequence shunt parts are floats if provided (no >0 check) + itertools.product(["gf0_pu"], not_floats_list), + itertools.product(["bf0_pu"], not_floats_list), + itertools.product(["gt0_pu"], not_floats_list), + itertools.product(["bt0_pu"], not_floats_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_impedance( + net, + from_bus=b0, + to_bus=b1, + rft_pu=0.0, + xft_pu=0.0, + rtf_pu=0.0, + xtf_pu=0.0, + gf_pu=0.0, + bf_pu=0.0, + gt_pu=0.0, + bt_pu=0.0, + sn_mva=10.0, + in_service=True, + ) + net.impedance[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestImpedanceResults: + """Tests for impedance results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_impedance_power_results(self): + """Test: Power results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_impedance_currents_results(self): + """Test: Currents results are within valid range""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_line_dc_elements.py b/pandapower/test/network_schema/elements/test_pandera_line_dc_elements.py new file mode 100644 index 0000000000..13bf793639 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_line_dc_elements.py @@ -0,0 +1,440 @@ +# test_pandera_line_dc_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus_dc, create_line_dc_from_parameters, create_line_dc +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_ints, + negativ_ints, + negativ_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + all_allowed_floats, + not_ints_list, +) + +# df must be in [0, 1] + +df_valid_range = [0.0, 0.5, 1.0] +df_invalid_range = [-0.1, 1.1] + +STD_TYPE = "NAYY 4x50 SE" + + +class TestLineDcRequiredFields: + """Tests for required line_dc fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["from_bus_dc"], positiv_ints_plus_zero), + itertools.product(["to_bus_dc"], positiv_ints_plus_zero), + itertools.product(["length_km"], positiv_floats), + itertools.product(["r_ohm_per_km"], positiv_floats_plus_zero), + itertools.product(["g_us_per_km"], positiv_floats_plus_zero), + itertools.product(["max_i_ka"], positiv_floats_plus_zero), + itertools.product(["parallel"], positiv_ints), + itertools.product(["df"], df_valid_range), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus_dc(net, 0.4) # index 0 + create_bus_dc(net, 0.4) # index 1 + create_bus_dc(net, 0.4, index=42) + + create_line_dc_from_parameters( + net, + from_bus_dc=0, + to_bus_dc=1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + net.line_dc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["from_bus_dc"], [*negativ_ints, *not_ints_list]), + itertools.product(["to_bus_dc"], [*negativ_ints, *not_ints_list]), + itertools.product(["length_km"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["r_ohm_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["g_us_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["max_i_ka"], [*negativ_floats, *not_floats_list]), + itertools.product(["parallel"], [*negativ_ints_plus_zero, *not_ints_list]), + itertools.product(["df"], [*df_invalid_range, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus_dc(net, 0.4) # index 0 + create_bus_dc(net, 0.4) # index 1 + + create_line_dc_from_parameters( + net, + from_bus_dc=0, + to_bus_dc=1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + net.line_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineDcOptionalFields: + """Tests for optional line_dc fields and TDPF group dependencies""" + + def test_all_optional_fields_valid(self): + """Test: line_dc with every optional field and complete TDPF group is valid""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + create_line_dc( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + name="Line DC A", + std_type="95-CU", + type="ol", + geo='{"type":"LineString","coordinates":[]}', + ) + + # Additional optional numeric fields + net.line_dc["max_loading_percent"] = 100.0 + net.line_dc["alpha"] = 0.00393 + net.line_dc["temperature_degree_celsius"] = 25.0 + + # TDPF group (must be complete if any is set) + net.line_dc["tdpf"] = pd.Series([True], dtype="boolean") + net.line_dc["wind_speed_m_per_s"] = 5.0 + net.line_dc["wind_angle_degree"] = 90.0 + net.line_dc["conductor_outer_diameter_m"] = 0.03 + net.line_dc["air_temperature_degree_celsius"] = 20.0 + net.line_dc["reference_temperature_degree_celsius"] = 20.0 + net.line_dc["solar_radiation_w_per_sq_m"] = 200.0 + net.line_dc["solar_absorptivity"] = 0.5 + net.line_dc["emissivity"] = 0.9 + net.line_dc["r_theta_kelvin_per_mw"] = 2.0 + net.line_dc["mc_joule_per_m_k"] = 3600.0 + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid when TDPF group is not triggered""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + # Line 1: string fields + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + name="Line 1", + type="ol", + ) + # Line 2: max_loading_percent only + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + max_loading_percent=80.0, + ) + + # Line 3: alpha/temperature only + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + alpha=0.003, + temperature_degree_celsius=30.0, + ) + + # Allow pd.NA in strings + net.line_dc["std_type"] = pd.Series(data=pd.NA, dtype=pd.StringDtype) + net.line_dc["type"] = pd.Series(data=pd.NA, dtype=pd.StringDtype) + net.line_dc["geo"] = pd.Series(data=pd.NA, dtype=pd.StringDtype) + + validate_network(net) + + def test_tdpf_group_partial_missing_invalid(self): + """Test: TDPF group must be complete if any TDPF value is set""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + # Case 1: tdpf flag only -> invalid + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + net.line_dc["tdpf"] = pd.Series([True], dtype="boolean") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 2: one tdpf param only -> invalid + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + net.line_dc["wind_speed_m_per_s"] = 3.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 3: another tdpf param only -> invalid + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + net.line_dc["reference_temperature_degree_celsius"] = 20.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["std_type"], strings), + itertools.product(["type"], strings), + itertools.product(["geo"], strings), + itertools.product(["alpha"], all_allowed_floats), + itertools.product(["temperature_degree_celsius"], all_allowed_floats), + itertools.product(["max_loading_percent"], positiv_floats), + itertools.product(["tdpf"], bools), + itertools.product(["wind_speed_m_per_s"], all_allowed_floats), + itertools.product(["wind_angle_degree"], all_allowed_floats), + itertools.product(["conductor_outer_diameter_m"], all_allowed_floats), + itertools.product(["air_temperature_degree_celsius"], all_allowed_floats), + itertools.product(["reference_temperature_degree_celsius"], all_allowed_floats), + itertools.product(["solar_radiation_w_per_sq_m"], all_allowed_floats), + itertools.product(["solar_absorptivity"], all_allowed_floats), + itertools.product(["emissivity"], all_allowed_floats), + itertools.product(["r_theta_kelvin_per_mw"], all_allowed_floats), + itertools.product(["mc_joule_per_m_k"], all_allowed_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted (TDPF group satisfied)""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + + # Satisfy TDPF group + net.line_dc["tdpf"] = pd.Series([True], dtype="boolean") + net.line_dc["wind_speed_m_per_s"] = 2.0 + net.line_dc["wind_angle_degree"] = 90.0 + net.line_dc["conductor_outer_diameter_m"] = 0.03 + net.line_dc["air_temperature_degree_celsius"] = 25.0 + net.line_dc["reference_temperature_degree_celsius"] = 20.0 + net.line_dc["solar_radiation_w_per_sq_m"] = 150.0 + net.line_dc["solar_absorptivity"] = 0.5 + net.line_dc["emissivity"] = 0.8 + net.line_dc["r_theta_kelvin_per_mw"] = 2.0 + net.line_dc["mc_joule_per_m_k"] = 3600.0 + + if parameter in {"name", "std_type", "type", "geo"}: + net.line_dc[parameter] = pd.Series([valid_value], dtype="string") + elif parameter == "tdpf": + net.line_dc[parameter] = pd.Series([valid_value], dtype="boolean") + else: + net.line_dc[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["std_type"], not_strings_list), + itertools.product(["type"], not_strings_list), + itertools.product(["geo"], not_strings_list), + itertools.product(["alpha"], not_floats_list), + itertools.product(["temperature_degree_celsius"], not_floats_list), + itertools.product(["max_loading_percent"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["tdpf"], not_boolean_list), + itertools.product(["wind_speed_m_per_s"], not_floats_list), + itertools.product(["wind_angle_degree"], not_floats_list), + itertools.product(["conductor_outer_diameter_m"], not_floats_list), + itertools.product(["air_temperature_degree_celsius"], not_floats_list), + itertools.product(["reference_temperature_degree_celsius"], not_floats_list), + itertools.product(["solar_radiation_w_per_sq_m"], not_floats_list), + itertools.product(["solar_absorptivity"], not_floats_list), + itertools.product(["emissivity"], not_floats_list), + itertools.product(["r_theta_kelvin_per_mw"], not_floats_list), + itertools.product(["mc_joule_per_m_k"], not_floats_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected (TDPF group satisfied)""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + + # Provide complete TDPF group so only the target parameter triggers failure + net.line_dc["tdpf"] = pd.Series([True], dtype="boolean") + net.line_dc["wind_speed_m_per_s"] = 2.0 + net.line_dc["wind_angle_degree"] = 90.0 + net.line_dc["conductor_outer_diameter_m"] = 0.03 + net.line_dc["air_temperature_degree_celsius"] = 25.0 + net.line_dc["reference_temperature_degree_celsius"] = 20.0 + net.line_dc["solar_radiation_w_per_sq_m"] = 150.0 + net.line_dc["solar_absorptivity"] = 0.5 + net.line_dc["emissivity"] = 0.8 + net.line_dc["r_theta_kelvin_per_mw"] = 2.0 + net.line_dc["mc_joule_per_m_k"] = 3600.0 + + net.line_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineDcForeignKey: + """Tests for foreign key constraints on dc bus indices""" + + def test_invalid_bus_index(self): + """Test: from_bus_dc/to_bus_dc must reference existing bus_dc indices""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + b1 = create_bus_dc(net, 0.4) + + create_line_dc_from_parameters( + net, + from_bus_dc=b0, + to_bus_dc=b1, + length_km=1.0, + r_ohm_per_km=0.1, + g_us_per_km=0.0, + max_i_ka=0.2, + parallel=1, + df=0.5, + in_service=True, + ) + + net.line_dc["from_bus_dc"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineDcResults: + """Tests for line_dc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_line_dc_result_totals(self): + """Test: aggregated p_mw results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_line_elements.py b/pandapower/test/network_schema/elements/test_pandera_line_elements.py new file mode 100644 index 0000000000..4adef3a463 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_line_elements.py @@ -0,0 +1,380 @@ +# test_pandera_line_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_line +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_ints, + negativ_ints, + negativ_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + all_allowed_floats, + not_ints_list, +) + +# Additional ranges and helpers + +df_valid_range = [0.0, 0.5, 1.0] +df_invalid_range = [-0.1, 1.1] +wind_angle_valid = [0.0, 90.0, 360.0] +wind_angle_invalid = [-0.1, 360.1] +endtemp_valid = [-200.0, 0.0, 40.0] +endtemp_invalid = [-1000.0, -274.0] +temp_valid = [-200.0, 0.0, 25.0] +temp_invalid = [-1000.0, -274.0] +ref_temp_valid = [-200.0, 20.0] +ref_temp_invalid = [-1000.0, -274.0] + +STD_TYPE = "NAYY 4x50 SE" + + +class TestLineRequiredFields: + """Tests for required line fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["from_bus"], positiv_ints_plus_zero), + itertools.product(["to_bus"], positiv_ints_plus_zero), + itertools.product(["length_km"], positiv_floats), + itertools.product(["r_ohm_per_km"], positiv_floats_plus_zero), + itertools.product(["x_ohm_per_km"], positiv_floats_plus_zero), + itertools.product(["c_nf_per_km"], positiv_floats_plus_zero), + itertools.product(["g_us_per_km"], positiv_floats_plus_zero), + itertools.product(["max_i_ka"], positiv_floats), + itertools.product(["parallel"], positiv_ints), + itertools.product(["df"], df_valid_range), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure FK-positive for 42 + + create_line(net, from_bus=0, to_bus=1, length_km=1.0, in_service=True, std_type="NAYY 4x50 SE") + + net.line[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["from_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["to_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["length_km"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["r_ohm_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["x_ohm_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["c_nf_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["g_us_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["max_i_ka"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["parallel"], [*negativ_ints_plus_zero, *not_ints_list]), + itertools.product(["df"], [*df_invalid_range, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + + create_line(net, from_bus=0, to_bus=1, length_km=1.0, in_service=True, std_type="NAYY 4x50 SE") + + net.line[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineOptionalFields: + """Tests for optional line fields and group dependencies (tdpf, opf)""" + + def test_all_optional_fields_valid(self): + """Test: line with every optional field and tdpf group complete is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type=STD_TYPE) + + # Optional text fields + net.line["name"] = pd.Series(["Line A"], dtype="string") + net.line["type"] = pd.Series(["ol"], dtype="string") + net.line["geo"] = pd.Series(['{"type":"LineString","coordinates":[]}'], dtype="string") + + # Zero-sequence params + net.line["r0_ohm_per_km"] = 0.0 + net.line["x0_ohm_per_km"] = 0.0 + net.line["c0_nf_per_km"] = 0.0 + net.line["g0_us_per_km"] = 0.0 + + # OPF group + net.line["max_loading_percent"] = 100.0 + + # Thermal params + net.line["alpha"] = 0.00393 + net.line["temperature_degree_celsius"] = 25.0 + net.line["endtemp_degree"] = 40.0 + + # TDPF group (complete) + net.line["tdpf"] = pd.Series([True], dtype="boolean") + net.line["wind_speed_m_per_s"] = 5.0 + net.line["wind_angle_degree"] = 90.0 + net.line["conductor_outer_diameter_m"] = 0.03 + net.line["air_temperature_degree_celsius"] = 20.0 + net.line["reference_temperature_degree_celsius"] = 20.0 + net.line["solar_radiation_w_per_sq_m"] = 200.0 + net.line["solar_absorptivity"] = 0.5 + net.line["emissivity"] = 0.9 + net.line["r_theta_kelvin_per_mw"] = 2.0 + net.line["mc_joule_per_m_k"] = 3600.0 + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid when tdpf group is not triggered""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + # Line 1: name/type/alpha + create_line( + net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, name="test", alpha=0.0, std_type=STD_TYPE + ) + # Line 2: max_loading_percent only (opf) + create_line( + net, + from_bus=b0, + to_bus=b1, + length_km=1.0, + in_service=True, + name="test", + alpha=0.0, + max_loading_percent=80.0, + std_type=STD_TYPE, + ) + # Line 3: zero-sequence params only + create_line( + net, + from_bus=b0, + to_bus=b1, + length_km=1.0, + in_service=True, + name="test", + alpha=0.0, + r0_ohm_per_km=0.1, + x0_ohm_per_km=0.2, + c0_nf_per_km=1.0, + g0_us_per_km=0.0, + std_type=STD_TYPE, + ) + + net.line["name"].iat[0] = pd.NA + net.line["std_type"].iat[1] = pd.NA + net.line["geo"].iat[2] = pd.NA + + validate_network(net) + + def test_tdpf_group_partial_missing_invalid(self): + """Test: tdpf group must be complete if any tdpf value is set""" + + # Case 1: tdpf flag only -> invalid + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type=STD_TYPE) + net.line["tdpf"] = True + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 2: one tdpf param only -> invalid + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type=STD_TYPE) + net.line["wind_speed_m_per_s"] = 3.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 3: another tdpf param only -> invalid + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type=STD_TYPE) + net.line["reference_temperature_degree_celsius"] = 20.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + # TODO sc, 3ph not beeing checked in line.py + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["std_type"], strings), + itertools.product(["type"], strings), + itertools.product(["geo"], strings), + itertools.product(["r0_ohm_per_km"], positiv_floats_plus_zero), + itertools.product(["x0_ohm_per_km"], positiv_floats_plus_zero), + itertools.product(["c0_nf_per_km"], positiv_floats_plus_zero), + itertools.product(["g0_us_per_km"], positiv_floats_plus_zero), + itertools.product(["max_loading_percent"], positiv_floats), + itertools.product(["endtemp_degree"], endtemp_valid), + itertools.product(["alpha"], all_allowed_floats), + itertools.product(["temperature_degree_celsius"], temp_valid), + itertools.product(["tdpf"], bools), + itertools.product(["wind_speed_m_per_s"], positiv_floats_plus_zero), + itertools.product(["wind_angle_degree"], wind_angle_valid), + itertools.product(["conductor_outer_diameter_m"], positiv_floats_plus_zero), + itertools.product(["air_temperature_degree_celsius"], all_allowed_floats), + itertools.product(["reference_temperature_degree_celsius"], ref_temp_valid), + itertools.product(["solar_radiation_w_per_sq_m"], positiv_floats_plus_zero), + itertools.product(["solar_absorptivity"], all_allowed_floats), + itertools.product(["emissivity"], all_allowed_floats), + itertools.product(["r_theta_kelvin_per_mw"], all_allowed_floats), + itertools.product(["mc_joule_per_m_k"], all_allowed_floats), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted (tdpf group satisfied)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type="NAYY 4x50 SE") + + # Satisfy tdpf group to avoid dependency failures when setting tdpf-related columns + net.line["tdpf"] = pd.Series([True], dtype="boolean") + net.line["wind_speed_m_per_s"] = 2.0 + net.line["wind_angle_degree"] = 90.0 + net.line["conductor_outer_diameter_m"] = 0.03 + net.line["air_temperature_degree_celsius"] = 25.0 + net.line["reference_temperature_degree_celsius"] = 20.0 + net.line["solar_radiation_w_per_sq_m"] = 150.0 + net.line["solar_absorptivity"] = 0.5 + net.line["emissivity"] = 0.8 + net.line["r_theta_kelvin_per_mw"] = 2.0 + net.line["mc_joule_per_m_k"] = 3600.0 + net.line["endtemp_degree"] = 40.0 + + if parameter in {"name", "std_type", "type", "geo"}: + net.line[parameter] = pd.Series([valid_value], dtype="string") + elif parameter == "tdpf": + net.line[parameter] = pd.Series([valid_value], dtype="boolean") + else: + net.line[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["std_type"], not_strings_list), + itertools.product(["type"], not_strings_list), + itertools.product(["geo"], not_strings_list), + itertools.product(["r0_ohm_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["x0_ohm_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["c0_nf_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["g0_us_per_km"], [*negativ_floats, *not_floats_list]), + itertools.product(["max_loading_percent"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["endtemp_degree"], [*endtemp_invalid, *not_floats_list]), + itertools.product(["alpha"], not_floats_list), + itertools.product(["temperature_degree_celsius"], [*temp_invalid, *not_floats_list]), + itertools.product(["tdpf"], not_boolean_list), + itertools.product(["wind_speed_m_per_s"], [*negativ_floats, *not_floats_list]), + itertools.product(["wind_angle_degree"], [*wind_angle_invalid, *not_floats_list]), + itertools.product(["conductor_outer_diameter_m"], not_floats_list), + itertools.product(["air_temperature_degree_celsius"], not_floats_list), + itertools.product(["reference_temperature_degree_celsius"], [*ref_temp_invalid, *not_floats_list]), + itertools.product(["solar_radiation_w_per_sq_m"], not_floats_list), + itertools.product(["solar_absorptivity"], not_floats_list), + itertools.product(["emissivity"], not_floats_list), + itertools.product(["r_theta_kelvin_per_mw"], not_floats_list), + itertools.product(["mc_joule_per_m_k"], not_floats_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected (tdpf group satisfied)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type="NAYY 4x50 SE") + + # Provide complete tdpf group so only the target parameter triggers failure + net.line["tdpf"] = pd.Series([True], dtype="boolean") + net.line["wind_speed_m_per_s"] = 2.0 + net.line["wind_angle_degree"] = 90.0 + net.line["conductor_outer_diameter_m"] = 0.03 + net.line["air_temperature_degree_celsius"] = 25.0 + net.line["reference_temperature_degree_celsius"] = 20.0 + net.line["solar_radiation_w_per_sq_m"] = 150.0 + net.line["solar_absorptivity"] = 0.5 + net.line["emissivity"] = 0.8 + net.line["r_theta_kelvin_per_mw"] = 2.0 + net.line["mc_joule_per_m_k"] = 3600.0 + net.line["endtemp_degree"] = 40.0 + + net.line[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineForeignKey: + """Tests for foreign key constraints on bus indices""" + + def test_invalid_bus_index(self): + """Test: from_bus/to_bus must reference existing bus indices""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_line(net, from_bus=b0, to_bus=b1, length_km=1.0, in_service=True, std_type="NAYY 4x50 SE") + + net.line["from_bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLineResults: + """Tests for line results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_line_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_line_3ph_results(self): + """Test: 3-phase results contain valid values per phase""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_line_sc_results(self): + """Test: short-circuit results contain valid values""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_load_dc_elements.py b/pandapower/test/network_schema/elements/test_pandera_load_dc_elements.py new file mode 100644 index 0000000000..b326d5f242 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_load_dc_elements.py @@ -0,0 +1,189 @@ +# test_pandera_load_dc_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus_dc, create_load_dc +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + all_allowed_floats, + negativ_floats, + positiv_floats_plus_zero, +) + + +class TestLoadDcRequiredFields: + """Tests for required load_dc fields""" + + @pytest.mark.parametrize( + "parameter, valid_value", + list( + itertools.chain( + itertools.product(["bus_dc"], positiv_ints_plus_zero), + itertools.product(["p_dc_mw"], all_allowed_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus_dc(net, 0.4) # index 0 + create_bus_dc(net, 0.4) # index 1 + create_bus_dc(net, 0.4, index=42) + + create_load_dc(net, bus_dc=0, p_dc_mw=1.0, scaling=1.0, in_service=True) + net.load_dc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter, invalid_value", + list( + itertools.chain( + itertools.product(["bus_dc"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_dc_mw"], not_floats_list), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus_dc(net, 0.4) # index 0 + create_bus_dc(net, 0.4) # index 1 + + create_load_dc(net, bus_dc=0, p_dc_mw=1.0, scaling=1.0, in_service=True) + net.load_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadDcOptionalFields: + """Tests for optional load_dc fields""" + + def test_all_optional_fields_valid(self): + """Test: load_dc with all optional fields is valid""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + create_load_dc( + net, + bus_dc=b0, + p_dc_mw=1.2, + scaling=1.0, + in_service=True, + name="LoadDC A", + type="consumer", + zone="area-1", + controllable=True, + ) + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + # Row 1: strings None, controllable NA + create_load_dc(net, bus_dc=b0, p_dc_mw=0.5, scaling=1.0, in_service=True, name=None) + # Row 2: set strings and controllable True + create_load_dc(net, bus_dc=b0, p_dc_mw=1.0, scaling=0.8, in_service=True, type="prosumer", zone="Z2") + # Row 3: controllable False + create_load_dc(net, bus_dc=b0, p_dc_mw=2.0, scaling=1.1, in_service=False) + + # Cast to required extension dtypes and set nulls + net.load_dc["name"] = pd.Series([pd.NA, "L2", "L3"], dtype="string") + net.load_dc["type"] = pd.Series([pd.NA, "prosumer", pd.NA], dtype="string") + net.load_dc["zone"] = pd.Series(["Z1", "Z2", pd.NA], dtype="string") + + validate_network(net) + + @pytest.mark.parametrize( + "parameter, valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["type"], strings), + itertools.product(["zone"], strings), + itertools.product(["controllable"], bools), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + create_load_dc(net, bus_dc=b0, p_dc_mw=1.0, scaling=1.0, in_service=True) + + if parameter in {"name", "type", "zone"}: + net.load_dc[parameter] = pd.Series([valid_value], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter, invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], not_strings_list), + itertools.product(["zone"], not_strings_list), + itertools.product(["controllable"], not_boolean_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + create_load_dc( + net, + bus_dc=b0, + p_dc_mw=1.0, + scaling=1.0, + in_service=True, + # set valid defaults so only target param fails + name="ok", + type="ok", + zone="ok", + controllable=True, + ) + + net.load_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadDcForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus_dc must reference an existing bus_dc index""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + create_load_dc(net, bus_dc=b0, p_dc_mw=1.0, scaling=1.0, in_service=True) + + net.load_dc["bus_dc"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadDcResults: + """Tests for load_dc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_load_dc_result_totals(self): + """Test: aggregated p_dc_mw results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_load_elements.py b/pandapower/test/network_schema/elements/test_pandera_load_elements.py new file mode 100644 index 0000000000..4af14cb664 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_load_elements.py @@ -0,0 +1,241 @@ +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_load +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + +# ZIP percentage ranges + +percent_valid = [0.0, 50.0, 100.0] +percent_invalid = [-0.1, 100.1] + + +class TestLoadRequiredFields: + """Tests for required load fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], all_allowed_floats), + itertools.product(["q_mvar"], all_allowed_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_load(net, bus=0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.load[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], not_floats_list), + itertools.product(["q_mvar"], not_floats_list), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + + create_load(net, bus=0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.load[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadOptionalFields: + """Tests for optional load fields and ZIP group dependencies""" + + def test_all_optional_fields_valid(self): + """Test: load with all optional fields and complete ZIP group is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_load( + net, + bus=b0, + p_mw=1.2, + q_mvar=0.3, + scaling=1.0, + in_service=True, + name="Load A", + sn_mva=1.0, + type="wye", + zone="area-1", + max_p_mw=2.0, + min_p_mw=-1.0, + max_q_mvar=1.0, + min_q_mvar=-0.5, + ) + # ZIP group (complete) + net.load["const_z_p_percent"] = 20.0 + net.load["const_i_p_percent"] = 30.0 + net.load["const_z_q_percent"] = 10.0 + net.load["const_i_q_percent"] = 40.0 + + # nullable boolean + net.load["controllable"] = pd.Series([True], dtype=bool) + + # ensure string dtypes for string columns + net.load["name"] = net.load["name"].astype("string") + net.load["type"] = net.load["type"].astype("string") + net.load["zone"] = net.load["zone"].astype("string") + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid when ZIP group is not triggered""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Create 3 loads with different optional fields + create_load(net, bus=b0, p_mw=1.0, q_mvar=0.1, scaling=1.0, in_service=True) + create_load(net, bus=b0, p_mw=0.5, q_mvar=0.0, scaling=0.8, in_service=True) + create_load(net, bus=b0, p_mw=2.0, q_mvar=0.2, scaling=1.1, in_service=False) + + # Assign optional fields with nulls + net.load["name"] = pd.Series(["L1", pd.NA, "L3"], dtype="string") + net.load["zone"] = pd.Series(["Z1", pd.NA, "Z3"], dtype="string") + net.load["type"] = pd.Series([pd.NA, "delta", pd.NA], dtype="string") + net.load["sn_mva"] = [None, 2.0, None] + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["type"], ["wye", "delta"]), + itertools.product(["zone"], strings), + itertools.product(["sn_mva"], positiv_floats), # gt(0) + itertools.product(["controllable"], bools), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["max_q_mvar"], all_allowed_floats), + itertools.product(["min_q_mvar"], all_allowed_floats), + itertools.product(["const_z_p_percent"], percent_valid), + itertools.product(["const_i_p_percent"], percent_valid), + itertools.product(["const_z_q_percent"], percent_valid), + itertools.product(["const_i_q_percent"], percent_valid), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted (ZIP group satisfied when needed)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_load(net, bus=b0, p_mw=1.0, q_mvar=0.1, scaling=1.0, in_service=True) + + # Satisfy ZIP group to avoid dependency failures when setting any ZIP-related column + net.load["const_z_p_percent"] = 20.0 + net.load["const_i_p_percent"] = 30.0 + net.load["const_z_q_percent"] = 10.0 + net.load["const_i_q_percent"] = 40.0 + + if parameter in {"name", "type", "zone"}: + net.load[parameter] = pd.Series([valid_value], dtype="string") + elif parameter == "controllable": + net.load[parameter] = pd.Series([valid_value], dtype=bool) + else: + net.load[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], [*strings, *not_strings_list]), # anything but 'wye'/'delta' + itertools.product(["zone"], not_strings_list), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["max_q_mvar"], not_floats_list), + itertools.product(["min_q_mvar"], not_floats_list), + itertools.product(["const_z_p_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["const_i_p_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["const_z_q_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["const_i_q_percent"], [*percent_invalid, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected (ZIP group satisfied when needed)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_load(net, bus=b0, p_mw=1.0, q_mvar=0.1, scaling=1.0, in_service=True) + + # Provide complete ZIP group so only the target parameter triggers failure + net.load["const_z_p_percent"] = 20.0 + net.load["const_i_p_percent"] = 30.0 + net.load["const_z_q_percent"] = 10.0 + net.load["const_i_q_percent"] = 40.0 + + net.load[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_load(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + + net.load["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestLoadResults: + """Tests for load results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_load_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_measurment_elements.py b/pandapower/test/network_schema/elements/test_pandera_measurment_elements.py new file mode 100644 index 0000000000..29cb463e76 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_measurment_elements.py @@ -0,0 +1,222 @@ +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + all_allowed_floats, + all_allowed_ints, +) + +valid_measurement_types = ["p", "q", "i", "v"] +valid_element_types = ["bus", "line", "trafo", "trafo3w", "load", "gen", "sgen", "shunt", "ward", "xward", "ext_grid"] +invalid_measurement_types = ["power", "voltage", "current", "", " "] +invalid_element_types = ["branch", "generator", "line3w", "", " "] + + +class TestMeasurementRequiredFields: + """Tests for required measurement fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["measurement_type"], valid_measurement_types), + itertools.product(["element_type"], valid_element_types), + itertools.product(["value"], all_allowed_floats), + itertools.product(["std_dev"], all_allowed_floats), + itertools.product(["element"], all_allowed_ints), + itertools.product(["check_existing"], bools), + itertools.product(["side"], strings), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m1"], dtype="string"), + "measurement_type": ["p"], + "element_type": ["bus"], + "value": [10.0], + "std_dev": [0.1], + "bus": [0], + "element": [0], + "check_existing": [True], + "side": ["hv"], + } + ) + if parameter in {"name"}: + net.measurement[parameter] = pd.Series([valid_value], dtype="string") + else: + net.measurement[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["measurement_type"], [*invalid_measurement_types, *not_strings_list]), + itertools.product(["element_type"], [*invalid_element_types, *not_strings_list]), + itertools.product(["value"], not_floats_list), + itertools.product(["std_dev"], not_floats_list), + itertools.product(["element"], not_ints_list), + itertools.product(["check_existing"], not_boolean_list), + itertools.product(["side"], not_strings_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m1"], dtype="string"), + "measurement_type": ["p"], + "element_type": ["bus"], + "value": [10.0], + "std_dev": [0.1], + "bus": [0], + "element": [0], + "check_existing": [True], + "side": ["hv"], + } + ) + + net.measurement[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMeasurementOptionalFields: + """Tests for optional measurement fields""" + + def test_measurement_without_bus_column_is_valid(self): + """Test: 'bus' column is optional and may be absent""" + net = create_empty_network() + create_bus(net, 0.4) + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m1"], dtype="string"), + "measurement_type": ["q"], + "element_type": ["gen"], + "value": [5.0], + "std_dev": [0.2], + "element": [0], + "check_existing": [False], + "side": ["from"], + } + ) + validate_network(net) + + @pytest.mark.parametrize( + "valid_bus", + positiv_ints_plus_zero, + ) + def test_optional_bus_valid_values(self, valid_bus): + """Test: optional 'bus' column accepts valid values and FK passes if index exists""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m2"], dtype="string"), + "measurement_type": ["i"], + "element_type": ["line"], + "value": [3.3], + "std_dev": [0.05], + "bus": [0], + "element": [0], + "check_existing": [True], + "side": ["to"], + } + ) + net.measurement["bus"] = valid_bus + validate_network(net) + + @pytest.mark.parametrize( + "invalid_bus", + [*negativ_ints, *not_ints_list], + ) + def test_optional_bus_invalid_values(self, invalid_bus): + """Test: optional 'bus' column rejects invalid values""" + net = create_empty_network() + create_bus(net, 0.4) + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m3"], dtype="string"), + "measurement_type": ["v"], + "element_type": ["trafo"], + "value": [1.01], + "std_dev": [0.01], + "bus": [0], + "element": [0], + "check_existing": [True], + "side": ["hv"], + } + ) + net.measurement["bus"] = invalid_bus + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMeasurementForeignKey: + """Tests for foreign key constraints (bus FK)""" + + def test_invalid_bus_index(self): + """Test: bus must reference an existing bus index if present""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + net.measurement = pd.DataFrame( + { + "name": pd.Series(["m4"], dtype="string"), + "measurement_type": ["p"], + "element_type": ["bus"], + "value": [2.0], + "std_dev": [0.1], + "bus": [b0], + "element": [b0], + "check_existing": [True], + "side": ["hv"], + } + ) + + net.measurement["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMeasurementResults: + """Tests for measurement results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_measurement_usage(self): + """Test: measurement values are consumed correctly by state estimation""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_motor_elements.py b/pandapower/test/network_schema/elements/test_pandera_motor_elements.py new file mode 100644 index 0000000000..da43f56e92 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_motor_elements.py @@ -0,0 +1,305 @@ +# test_pandera_motor_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_motor +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats_plus_zero, + negativ_floats, + zero_float, +) + +# Ranges from schema + +ratio_valid = [0.0, 0.5, 1.0] # for cos_phi, cos_phi_n +ratio_invalid = [-0.1, 1.1] +percent_valid = [0.0, 50.0, 100.0] # for efficiency_percent, efficiency_n_percent, loading_percent +percent_invalid = [-0.1, 100.1] + + +class TestMotorRequiredFields: + """All columns except 'name' are required""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["pn_mech_mw"], positiv_floats_plus_zero), + itertools.product(["cos_phi"], [*ratio_valid, *zero_float]), + itertools.product(["cos_phi_n"], [*ratio_valid, *zero_float]), + itertools.product(["efficiency_percent"], [*percent_valid, *positiv_floats_plus_zero]), + itertools.product(["efficiency_n_percent"], [*percent_valid, *positiv_floats_plus_zero]), + itertools.product(["loading_percent"], [*percent_valid, *positiv_floats_plus_zero]), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["lrc_pu"], positiv_floats_plus_zero), + itertools.product(["rx"], positiv_floats_plus_zero), + itertools.product(["vn_kv"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + create_motor( + net, + bus=0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=50.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + name="M1", + ) + + net.motor[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["pn_mech_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["cos_phi"], [*ratio_invalid, *not_floats_list]), + itertools.product(["cos_phi_n"], [*ratio_invalid, *not_floats_list]), + itertools.product(["efficiency_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["efficiency_n_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["loading_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["lrc_pu"], [*negativ_floats, *not_floats_list]), + itertools.product(["rx"], [*negativ_floats, *not_floats_list]), + itertools.product(["vn_kv"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_motor( + net, + bus=0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=50.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + name="M1", + ) + + net.motor[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter", + [ + "pn_mech_mw", + "cos_phi", + "cos_phi_n", + "efficiency_percent", + "efficiency_n_percent", + "loading_percent", + "scaling", + "lrc_pu", + "rx", + "vn_kv", + "in_service", + ], + ) + def test_required_fields_nan_invalid(self, parameter): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_motor( + net, + bus=b0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=50.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + ) + + net.motor[parameter] = float(np.nan) + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMotorOptionalFields: + """Only 'name' is optional""" + + def test_optional_fields_with_nulls(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_motor( + net, + bus=b0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=60.0, + scaling=1.0, + lrc_pu=5.0, + rx=0.2, + vn_kv=0.4, + in_service=True, + name=None, + ) + create_motor( + net, + bus=b0, + pn_mech_mw=2.0, + cos_phi=0.8, + cos_phi_n=0.7, + efficiency_percent=85.0, + efficiency_n_percent=88.0, + loading_percent=40.0, + scaling=1.2, + lrc_pu=4.0, + rx=0.15, + vn_kv=0.4, + in_service=False, + name="M2", + ) + + net.motor["name"] = pd.Series([pd.NA, "M2"], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_motor( + net, + bus=b0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=60.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + name="M1", + ) + net.motor[parameter] = pd.Series([valid_value], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list(itertools.chain(itertools.product(["name"], not_strings_list))), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_motor( + net, + bus=b0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=60.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + name="ok", + ) + net.motor[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMotorForeignKey: + """Foreign key constraints""" + + def test_invalid_bus_index(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_motor( + net, + bus=b0, + pn_mech_mw=1.0, + cos_phi=0.9, + cos_phi_n=0.8, + efficiency_percent=90.0, + efficiency_n_percent=92.0, + loading_percent=50.0, + scaling=1.0, + lrc_pu=6.0, + rx=0.1, + vn_kv=0.4, + in_service=True, + ) + + net.motor["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestMotorResults: + """Motor results""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_motor_result_totals(self): + """Aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_sgen_elements.py b/pandapower/test/network_schema/elements/test_pandera_sgen_elements.py new file mode 100644 index 0000000000..beaa4322d3 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_sgen_elements.py @@ -0,0 +1,305 @@ +# test_pandera_sgen_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_sgen +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + negativ_floats, + not_ints_list, + negativ_ints, + all_allowed_floats, + all_allowed_ints, +) + + +class TestSgenRequiredFields: + """Tests for required sgen fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], all_allowed_floats), + itertools.product(["q_mvar"], all_allowed_floats), + itertools.product(["scaling"], positiv_floats_plus_zero), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + create_sgen(net, bus=0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.sgen[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], not_floats_list), + itertools.product(["q_mvar"], not_floats_list), + itertools.product(["scaling"], [*negativ_floats, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_sgen(net, bus=0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.sgen[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSgenOptionalFields: + """Tests for optional sgen fields and group dependencies (opf, qcc)""" + + def test_all_optional_fields_valid(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Create base sgen + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.2, scaling=1.0, in_service=True) + + # String/boolean optionals + net.sgen["name"] = pd.Series(["SGen A"], dtype="string") + net.sgen["type"] = pd.Series(["PV"], dtype="string") + net.sgen["controllable"] = pd.Series([True], dtype=bool) + + # OPF group (complete) + net.sgen["max_p_mw"] = 2.0 + net.sgen["min_p_mw"] = -2.0 + net.sgen["max_q_mvar"] = 1.0 + net.sgen["min_q_mvar"] = -1.0 + + # QCC group (complete) + net.sgen["id_q_capability_characteristic"] = pd.Series([0], dtype="Int64") + net.sgen["curve_style"] = pd.Series(["straightLineYValues"], dtype="string") + net.sgen["reactive_capability_curve"] = pd.Series([True], dtype="boolean") + + # SC-related optionals (not enforced by group dependency in schema) + net.sgen["sn_mva"] = 1.0 + net.sgen["k"] = 0.0 + net.sgen["rx"] = 0.0 + net.sgen["current_source"] = pd.Series([False], dtype="boolean") + net.sgen["generator_type"] = pd.Series(["async"], dtype="string") + net.sgen["lrc_pu"] = 5.0 + net.sgen["max_ik_ka"] = 10.0 + net.sgen["kappa"] = 1.8 + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optional fields incl. nulls; groups satisfied when present""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Row 1: OPF group complete + create_sgen( + net, + bus=b0, + p_mw=0.8, + q_mvar=0.1, + scaling=1.0, + in_service=True, + max_p_mw=1.5, + min_p_mw=-1.0, + max_q_mvar=0.8, + min_q_mvar=-0.6, + name="alpha", + ) + # Row 2: QCC group complete + create_sgen( + net, + bus=b0, + p_mw=1.2, + q_mvar=0.0, + scaling=0.9, + in_service=True, + id_q_capability_characteristic=1, + curve_style="constantYValue", + reactive_capability_curve=True, + type="wye", + ) + + # Row 3: other optionals without triggering groups + create_sgen(net, bus=b0, p_mw=2.0, q_mvar=-0.2, scaling=1.1, in_service=False, sn_mva=2.0) + net.sgen["name"] = pd.Series(["alpha", pd.NA, "gamma"], dtype="string") + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["type"], strings), + itertools.product(["sn_mva"], positiv_floats), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["max_q_mvar"], all_allowed_floats), + itertools.product(["min_q_mvar"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["k"], positiv_floats_plus_zero), + itertools.product(["rx"], positiv_floats_plus_zero), + itertools.product(["current_source"], bools), + itertools.product(["generator_type"], ["current_source", "async", "async_doubly_fed"]), + itertools.product(["lrc_pu"], all_allowed_floats), + itertools.product(["max_ik_ka"], all_allowed_floats), + itertools.product(["kappa"], all_allowed_floats), + itertools.product(["id_q_capability_characteristic"], all_allowed_ints), + itertools.product(["curve_style"], ["straightLineYValues", "constantYValue"]), + itertools.product(["reactive_capability_curve"], bools), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + + # Satisfy OPF + QCC groups so target column won't fail on dependency + net.sgen["max_p_mw"] = 2.0 + net.sgen["min_p_mw"] = -2.0 + net.sgen["max_q_mvar"] = 1.0 + net.sgen["min_q_mvar"] = -1.0 + net.sgen["id_q_capability_characteristic"] = pd.Series([0], dtype="Int64") + net.sgen["curve_style"] = pd.Series(["straightLineYValues"], dtype="string") + net.sgen["reactive_capability_curve"] = pd.Series([True], dtype="boolean") + + if parameter in {"name", "type", "curve_style", "generator_type"}: + net.sgen[parameter] = pd.Series([valid_value], dtype="string") + elif parameter in {"current_source", "reactive_capability_curve"}: + net.sgen[parameter] = pd.Series([valid_value], dtype=pd.BooleanDtype) + elif parameter == "id_q_capability_characteristic": + net.sgen[parameter] = pd.Series([valid_value], dtype="Int64") + else: + net.sgen[parameter] = valid_value + + validate_network(net) + + def test_opf_group_partial_missing_invalid(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + + # Set only one OPF column -> should fail + net.sgen["max_p_mw"] = 100.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_qcc_group_partial_missing_invalid(self): + # Only id_q_capability_characteristic + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.sgen["id_q_capability_characteristic"] = pd.Series([0], dtype="Int64") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Only curve_style + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.sgen["curve_style"] = pd.Series([pd.NA, "straightLineYValues"], dtype="string") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Only reactive_capability_curve + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + net.sgen["reactive_capability_curve"] = pd.Series([pd.NA, pd.NA, True], dtype="boolean") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["max_q_mvar"], not_floats_list), + itertools.product(["min_q_mvar"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["k"], [*negativ_floats, *not_floats_list]), + itertools.product(["rx"], [*negativ_floats, *not_floats_list]), + itertools.product(["current_source"], not_boolean_list), + itertools.product(["generator_type"], not_strings_list), + itertools.product(["lrc_pu"], not_floats_list), + itertools.product(["max_ik_ka"], not_floats_list), + itertools.product(["kappa"], not_floats_list), + itertools.product(["id_q_capability_characteristic"], not_ints_list), + itertools.product(["curve_style"], not_strings_list), + itertools.product(["reactive_capability_curve"], not_boolean_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + + # Provide complete OPF + QCC groups + net.sgen["max_p_mw"] = 2.0 + net.sgen["min_p_mw"] = -2.0 + net.sgen["max_q_mvar"] = 1.0 + net.sgen["min_q_mvar"] = -1.0 + net.sgen["id_q_capability_characteristic"] = pd.Series([0], dtype="Int64") + net.sgen["curve_style"] = pd.Series(["straightLineYValues"], dtype="string") + net.sgen["reactive_capability_curve"] = pd.Series([True], dtype="boolean") + + net.sgen[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSgenForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_sgen(net, bus=b0, p_mw=1.0, q_mvar=0.0, scaling=1.0, in_service=True) + + net.sgen["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSgenResults: + """Tests for sgen results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_sgen_result_totals(self): + """Aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_shunt_elements.py b/pandapower/test/network_schema/elements/test_pandera_shunt_elements.py new file mode 100644 index 0000000000..4302017951 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_shunt_elements.py @@ -0,0 +1,303 @@ +# test_pandera_shunt_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest +import numpy as np + +from pandapower.create import create_empty_network, create_bus, create_shunt +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_floats, + negativ_floats, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + positiv_ints, + negativ_ints_plus_zero, + negativ_ints, + not_ints_list, + all_allowed_floats, +) + + +class TestShuntRequiredFields: + """Tests for required shunt fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], positiv_floats_plus_zero), + itertools.product(["q_mvar"], all_allowed_floats), + itertools.product(["vn_kv"], positiv_floats), + itertools.product(["step"], positiv_floats), + itertools.product(["in_service"], bools), + itertools.product(["id_characteristic_table"], [pd.NA, *positiv_ints_plus_zero]), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_shunt( + net, bus=0, q_mvar=0.0, p_mw=0.0, in_service=True, vn_kv=0.4, step=1, id_characteristic_table=0, max_step=42 + ) + + if parameter == "id_characteristic_table": + net.shunt[parameter] = pd.Series([valid_value], dtype="Int64") + else: + net.shunt[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], [*negativ_floats, *not_floats_list]), + itertools.product(["q_mvar"], not_floats_list), + itertools.product(["vn_kv"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["step"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + itertools.product(["id_characteristic_table"], not_ints_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=1.0, + id_characteristic_table=0, + ) + + if parameter == "id_characteristic_table": + # If invalid_value is an int, keep Int64 dtype to trigger 'ge(0)' check; + # otherwise assign as-is to trigger dtype mismatch. + if isinstance(invalid_value, (int, np.integer)) and not isinstance(invalid_value, (bool, np.bool_)): + net.shunt[parameter] = pd.Series([invalid_value], dtype="Int64") + else: + net.shunt[parameter] = invalid_value + else: + net.shunt[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestShuntOptionalFields: + """Tests for optional shunt fields""" + + def test_all_optional_fields_valid(self): + """Test: shunt with all optional fields is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=2.0, + id_characteristic_table=0, + ) + # Optional fields + net.shunt["name"] = pd.Series(["Shunt A"], dtype=pd.StringDtype()) + net.shunt["max_step"] = pd.Series([3], dtype="Int64") + net.shunt["step_dependency_table"] = pd.Series([True], dtype=pd.BooleanDtype()) + + # Check passes if step <= max_step (2 <= 3) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Row 1: name only + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=1.0, + id_characteristic_table=0, + ) + net.shunt["name"] = pd.Series(["alpha"], dtype=pd.StringDtype()) + + # Row 2: max_step with NA name and NA step_dependency_table + create_shunt(net, bus=b0, q_mvar=0.1, p_mw=0.0, in_service=False) + net.shunt["vn_kv"].iat[1] = 0.4 + net.shunt["step"].iat[1] = 2.0 + net.shunt["id_characteristic_table"].iat[1] = pd.NA + net.shunt["name"].iat[1] = pd.NA + # max_step present; ensure step <= max_step + max_step_series = net.shunt.get("max_step", pd.Series([pd.NA] * len(net.shunt), dtype="Int64")) + if len(max_step_series) < len(net.shunt): + # Extend to match length + max_step_series = pd.concat([max_step_series, pd.Series([pd.NA], dtype="Int64")], ignore_index=True) + max_step_series.iat[1] = 3 + net.shunt["max_step"] = max_step_series + net.shunt["step_dependency_table"] = pd.Series([pd.NA, pd.NA], dtype=pd.BooleanDtype()) + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["max_step"], [1, 2, 5]), + itertools.product(["step_dependency_table"], [True, False]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=1.0, + id_characteristic_table=0, + ) + + from pandapower.create._utils import add_column_to_df + add_column_to_df(net, "shunt", parameter) + + if parameter == "max_step": + # Ensure step <= max_step + net.shunt["step"].at[0] = min(int(net.shunt["step"].iat[0]), int(valid_value)) + else: + net.shunt[parameter].at[0] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["max_step"], [*negativ_ints_plus_zero, *not_ints_list]), + itertools.product(["step_dependency_table"], not_boolean_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=1.0, + id_characteristic_table=0, + ) + net.shunt[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_step_less_equal_max_check_passes(self): + """Test: 'step' <= 'max_step' passes""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=2.0, + id_characteristic_table=0, + ) + net.shunt["max_step"] = pd.Series([3], dtype="Int64") + + validate_network(net) + + def test_step_greater_than_max_fails(self): + """Test: 'step' > 'max_step' fails""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=5.0, + id_characteristic_table=0, + ) + net.shunt["max_step"] = pd.Series([3], dtype="Int64") + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestShuntForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_shunt( + net, + bus=b0, + q_mvar=0.0, + p_mw=0.0, + in_service=True, + vn_kv=0.4, + step=1.0, + id_characteristic_table=0, + ) + + net.shunt["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestShuntResults: + """Tests for shunt results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_shunt_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_source_dc_elements.py b/pandapower/test/network_schema/elements/test_pandera_source_dc_elements.py new file mode 100644 index 0000000000..1e58515266 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_source_dc_elements.py @@ -0,0 +1,168 @@ +# test_pandera_source_dc_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus_dc, create_source_dc +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + all_allowed_floats, +) + + +class TestSourceDcRequiredFields: + """Tests for required source_dc fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus_dc"], positiv_ints_plus_zero), + itertools.product(["vm_pu"], all_allowed_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Valid required values are accepted""" + net = create_empty_network() + create_bus_dc(net, 0.4) # index 0 + create_bus_dc(net, 0.4) # index 1 + create_bus_dc(net, 0.4, index=42) + + create_source_dc(net, bus_dc=0, vm_pu=1.0, in_service=True) + net.source_dc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus_dc"], [*negativ_ints, *not_ints_list]), + itertools.product(["vm_pu"], not_floats_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Invalid required values are rejected""" + net = create_empty_network() + create_bus_dc(net, 0.4) # 0 + create_bus_dc(net, 0.4) # 1 + + create_source_dc(net, bus_dc=0, vm_pu=1.0, in_service=True) + net.source_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize("parameter", ["vm_pu", "in_service"]) + def test_required_fields_nan_invalid(self, parameter): + """NaN in required columns is invalid""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + create_source_dc(net, bus_dc=b0, vm_pu=1.0, in_service=True) + + net.source_dc[parameter] = float(np.nan) + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSourceDcOptionalFields: + """Tests for optional source_dc fields""" + + def test_all_optional_fields_valid(self): + """All optional fields set""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + create_source_dc(net, bus_dc=b0, vm_pu=1.02, in_service=True, name="SRC A", type="voltage_source") + # ensure string dtypes + net.source_dc["name"] = net.source_dc["name"].astype("string") + net.source_dc["type"] = net.source_dc["type"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + # Row 1: name None + create_source_dc(net, bus_dc=b0, vm_pu=1.0, in_service=True, name=None) + # Row 2: type set, name NA + create_source_dc(net, bus_dc=b0, vm_pu=0.98, in_service=False, type="converter") + + net.source_dc["name"] = pd.Series([pd.NA, "SRC-B"], dtype="string") + net.source_dc["type"] = pd.Series([pd.NA, "converter"], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["type"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + create_source_dc(net, bus_dc=b0, vm_pu=1.01, in_service=True, name="ok", type="ok") + net.source_dc[parameter] = pd.Series([valid_value], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], not_strings_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + + create_source_dc(net, bus_dc=b0, vm_pu=1.0, in_service=True, name="ok", type="ok") + net.source_dc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSourceDcForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """bus_dc must reference an existing bus_dc index""" + net = create_empty_network() + b0 = create_bus_dc(net, 0.4) + create_source_dc(net, bus_dc=b0, vm_pu=1.0, in_service=True) + + net.source_dc["bus_dc"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSourceDcResults: + """Tests for source_dc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_source_dc_result_totals(self): + """Aggregated p_dc_mw results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_ssc_elements.py b/pandapower/test/network_schema/elements/test_pandera_ssc_elements.py new file mode 100644 index 0000000000..85403a1e86 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_ssc_elements.py @@ -0,0 +1,244 @@ +# test_pandera_ssc_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_ssc +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + positiv_floats, + negativ_floats, + all_allowed_floats, +) + + +class TestSscRequiredFields: + """Tests for required SSC fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["r_ohm"], positiv_floats_plus_zero), + itertools.product(["x_ohm"], negativ_floats_plus_zero), + itertools.product(["set_vm_pu"], all_allowed_floats), + itertools.product(["vm_internal_pu"], all_allowed_floats), + itertools.product(["va_internal_degree"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + create_ssc( + net, + bus=0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + ) + net.ssc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["r_ohm"], [*negativ_floats, *not_floats_list]), + itertools.product(["x_ohm"], [*positiv_floats, *not_floats_list]), + itertools.product(["set_vm_pu"], not_floats_list), + itertools.product(["vm_internal_pu"], not_floats_list), + itertools.product(["va_internal_degree"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_ssc( + net, + bus=0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + ) + net.ssc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSscOptionalFields: + """Tests for optional SSC fields""" + + def test_all_optional_fields_valid(self): + """Test: SSC with all optional fields is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ssc( + net, + bus=b0, + r_ohm=0.1, + x_ohm=-0.2, + set_vm_pu=1.02, + vm_internal_pu=1.03, + va_internal_degree=3.0, + controllable=True, + in_service=False, + name="SSC A", + ) + net.ssc["name"] = net.ssc["name"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ssc( + net, + bus=b0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + name=None, + ) + create_ssc( + net, + bus=b0, + r_ohm=0.2, + x_ohm=-0.3, + set_vm_pu=0.98, + vm_internal_pu=0.99, + va_internal_degree=-2.0, + controllable=False, + in_service=False, + name="SSC B", + ) + + net.ssc["name"] = pd.Series([pd.NA, "SSC B"], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ssc( + net, + bus=b0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + name="ok", + ) + net.ssc[parameter] = pd.Series([valid_value], dtype="string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list(itertools.chain(itertools.product(["name"], not_strings_list))), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ssc( + net, + bus=b0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + name="ok", + ) + net.ssc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSscForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ssc( + net, + bus=b0, + r_ohm=0.0, + x_ohm=-0.1, + set_vm_pu=1.00, + vm_internal_pu=1.01, + va_internal_degree=0.0, + controllable=True, + in_service=True, + ) + net.ssc["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSscResults: + """Tests for ssc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_ssc_result_totals(self): + """Test: aggregated reactive power results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_storage_elements.py b/pandapower/test/network_schema/elements/test_pandera_storage_elements.py new file mode 100644 index 0000000000..30213235e7 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_storage_elements.py @@ -0,0 +1,254 @@ +# test_pandera_storage_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_storage +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + all_allowed_floats, + percent_valid, + percent_invalid, +) + + +class TestStorageRequiredFields: + """Tests for required storage fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["p_mw"], all_allowed_floats), + itertools.product(["q_mvar"], all_allowed_floats), + itertools.product(["sn_mva"], positiv_floats), + itertools.product(["scaling"], [*positiv_floats_plus_zero, *negativ_floats_plus_zero]), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + create_storage(net, bus=0, p_mw=0.5, q_mvar=0.1, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["p_mw"], not_floats_list), + itertools.product(["q_mvar"], not_floats_list), + itertools.product(["sn_mva"], not_floats_list), + itertools.product(["scaling"], not_floats_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + + create_storage(net, bus=0, p_mw=0.5, q_mvar=0.1, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestStorageOptionalFields: + """Tests for optional storage fields and OPF group dependencies""" + + def test_all_optional_fields_valid(self): + """All optional fields set and OPF group complete""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_storage(net, bus=b0, p_mw=0.5, q_mvar=0.1, scaling=1.0, in_service=True, max_e_mwh=10.0) + + # Optional fields + net.storage["name"] = pd.Series(["Storage A"], dtype="string") + net.storage["type"] = pd.Series(["li-ion"], dtype="string") + net.storage["sn_mva"] = 1.0 + net.storage["max_e_mwh"] = 10.0 + net.storage["min_e_mwh"] = 0.0 + net.storage["soc_percent"] = 50.0 + + # OPF group (must be complete if any set) + net.storage["max_p_mw"] = 1.5 + net.storage["min_p_mw"] = -1.5 + net.storage["max_q_mvar"] = 0.8 + net.storage["min_q_mvar"] = -0.8 + net.storage["controllable"] = pd.Series([True], dtype=bool) + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optional fields incl. nulls are valid when OPF group is not triggered""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Row 1 + create_storage(net, bus=b0, p_mw=0.2, q_mvar=0.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + # Row 2 + create_storage(net, bus=b0, p_mw=-0.4, q_mvar=0.1, scaling=1.0, in_service=False, max_e_mwh=10.0) + # Row 3 + create_storage(net, bus=b0, p_mw=0.0, q_mvar=-0.2, scaling=0.8, in_service=True, max_e_mwh=10.0) + + net.storage["name"] = pd.Series(["A", pd.NA, "C"], dtype="string") + net.storage["type"] = pd.Series([pd.NA, "flywheel", pd.NA], dtype="string") + net.storage["sn_mva"] = [float(np.nan), 2.0, float(np.nan)] + net.storage["soc_percent"] = [float(np.nan), 20.0, 75.0] + net.storage["max_e_mwh"] = [float(np.nan), float(np.nan), 5.0] + net.storage["min_e_mwh"] = [float(np.nan), 0.0, float(np.nan)] + + validate_network(net) + + def test_opf_group_partial_missing_invalid(self): + """OPF group must be complete if any OPF value is set""" + + # Case 1: only max_p_mw + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_storage(net, bus=b0, p_mw=0.1, q_mvar=0.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage["max_p_mw"] = 1.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 2: only controllable + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_storage(net, bus=b0, p_mw=0.2, q_mvar=0.1, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage["controllable"] = pd.Series([True], dtype="boolean") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + # Case 3: only min_q_mvar + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_storage(net, bus=b0, p_mw=-0.2, q_mvar=0.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage["min_q_mvar"] = -0.5 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["type"], strings), + itertools.product(["max_e_mwh"], all_allowed_floats), + itertools.product(["min_e_mwh"], all_allowed_floats), + itertools.product(["soc_percent"], percent_valid), + itertools.product(["max_p_mw"], all_allowed_floats), + itertools.product(["min_p_mw"], all_allowed_floats), + itertools.product(["max_q_mvar"], all_allowed_floats), + itertools.product(["min_q_mvar"], all_allowed_floats), + itertools.product(["controllable"], bools), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Valid optional values are accepted (OPF group satisfied when needed)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_storage(net, bus=b0, p_mw=0.3, q_mvar=0.0, sn_mva=1.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + + # Satisfy OPF group to avoid dependency failures + net.storage["max_p_mw"] = 1.0 + net.storage["min_p_mw"] = -1.0 + net.storage["max_q_mvar"] = 0.6 + net.storage["min_q_mvar"] = -0.6 + net.storage["controllable"] = pd.Series([True], dtype=bool) + + if parameter in {"name", "type"}: + net.storage[parameter] = pd.Series([valid_value], dtype="string") + elif parameter == "controllable": + net.storage[parameter] = pd.Series([valid_value], dtype=bool) + else: + net.storage[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], not_strings_list), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["max_e_mwh"], not_floats_list), + itertools.product(["min_e_mwh"], not_floats_list), + itertools.product(["soc_percent"], [*percent_invalid, *not_floats_list]), + itertools.product(["max_p_mw"], not_floats_list), + itertools.product(["min_p_mw"], not_floats_list), + itertools.product(["max_q_mvar"], not_floats_list), + itertools.product(["min_q_mvar"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Invalid optional values are rejected (OPF group satisfied)""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_storage(net, bus=b0, p_mw=0.3, q_mvar=0.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + + # Provide complete OPF group so only the target parameter triggers failure + net.storage["max_p_mw"] = 1.0 + net.storage["min_p_mw"] = -1.0 + net.storage["max_q_mvar"] = 0.6 + net.storage["min_q_mvar"] = -0.6 + net.storage["controllable"] = pd.Series([True], dtype="boolean") + + net.storage[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestStorageForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """bus must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_storage(net, bus=b0, p_mw=0.5, q_mvar=0.0, scaling=1.0, in_service=True, max_e_mwh=10.0) + net.storage["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestStorageResults: + """Tests for storage results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_storage_result_totals(self): + """Aggregated p_mw / q_mvar results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_svc_elements.py b/pandapower/test/network_schema/elements/test_pandera_svc_elements.py new file mode 100644 index 0000000000..f941b48a89 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_svc_elements.py @@ -0,0 +1,314 @@ +# test_pandera_svc_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_svc +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + all_floats, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + positiv_floats, + negativ_floats, + all_allowed_floats, +) + +# Angle ranges per schema (inclusive) + +valid_angle_range = [*[x for x in all_allowed_floats if 90 <= x <= 180], 90.0, 110.0, 180.0] +invalid_low_angle = [x for x in all_floats if x < 90] +invalid_high_angle = [x for x in all_floats if x > 180] +invalid_angle_range = invalid_low_angle + invalid_high_angle + + +class TestSvcRequiredFields: + """Tests for required SVC fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["x_l_ohm"], positiv_floats_plus_zero), + itertools.product(["x_cvar_ohm"], negativ_floats_plus_zero), + itertools.product(["set_vm_pu"], all_allowed_floats), + itertools.product(["thyristor_firing_angle_degree"], valid_angle_range), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_bus(net, 0.4, index=42) + + create_svc( + net, + bus=0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + + net.svc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["x_l_ohm"], [*negativ_floats, *not_floats_list]), + itertools.product(["x_cvar_ohm"], [*positiv_floats, *not_floats_list]), + itertools.product(["set_vm_pu"], not_floats_list), + itertools.product(["thyristor_firing_angle_degree"], [*invalid_angle_range, *not_floats_list]), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + + create_svc( + net, + bus=0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + + net.svc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSvcOptionalFields: + """Tests for optional SVC fields""" + + def test_all_optional_fields_valid(self): + """Test: SVC with all optional fields is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.1, + x_cvar_ohm=-0.2, + set_vm_pu=1.02, + thyristor_firing_angle_degree=120.0, + controllable=True, + in_service=False, + name="SVC A", + min_angle_degree=100.0, + max_angle_degree=150.0, + ) + # Ensure string dtype for name + net.svc["name"] = net.svc["name"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + # Row 1: name set, angles NaN later + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + name="alpha", + ) + # Row 2: min only initially (we'll null out max) + create_svc( + net, + bus=b0, + x_l_ohm=0.1, + x_cvar_ohm=-0.2, + set_vm_pu=0.98, + thyristor_firing_angle_degree=110.0, + controllable=False, + in_service=False, + min_angle_degree=95.0, + ) + # Row 3: max only initially (we'll null out min) + create_svc( + net, + bus=b0, + x_l_ohm=0.2, + x_cvar_ohm=-0.3, + set_vm_pu=1.05, + thyristor_firing_angle_degree=170.0, + controllable=True, + in_service=True, + max_angle_degree=175.0, + ) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["min_angle_degree"], [90.0, 100.0, 150.0]), + itertools.product(["max_angle_degree"], [180.0, 150.0, 100.0]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + + if parameter == "name": + net.svc[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.svc[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["min_angle_degree"], [*invalid_low_angle, *not_floats_list]), + itertools.product(["max_angle_degree"], [*invalid_high_angle, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + net.svc[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_min_less_equal_max_check_passes(self): + """Test: min_angle_degree <= max_angle_degree passes""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + net.svc["min_angle_degree"] = 100.0 + net.svc["max_angle_degree"] = 150.0 + + validate_network(net) + + def test_min_greater_than_max_fails(self): + """Test: min_angle_degree > max_angle_degree fails""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + net.svc["min_angle_degree"] = 160.0 + net.svc["max_angle_degree"] = 150.0 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSvcForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_svc( + net, + bus=b0, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_vm_pu=1.00, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=True, + ) + + net.svc["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSvcResults: + """Tests for svc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_svc_result_totals(self): + """Test: reactive power and impedance results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_switch_elements.py b/pandapower/test/network_schema/elements/test_pandera_switch_elements.py new file mode 100644 index 0000000000..6ba65df905 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_switch_elements.py @@ -0,0 +1,206 @@ +# test_pandera_switch_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_switch +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + all_allowed_floats, +) + + +class TestSwitchRequiredFields: + """Tests for required switch fields""" + + @pytest.mark.parametrize( + "parameter, valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["element"], positiv_ints_plus_zero), + itertools.product(["et"], [["b"], ["l"], ["t"], ["t3"]]), + itertools.product(["closed"], bools), + itertools.product(["in_ka"], positiv_floats), + itertools.product(["z_ohm"], all_allowed_floats), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + create_bus(net, 0.4, index=42) + + # Base: bus-bus switch between bus 0 and bus 1 + create_switch(net, bus=0, element=1, et="b", type="CB", closed=True, in_ka=1.0, z_ohm=1.0) + + # Assign the parameter + if parameter == "et": + net.switch[parameter] = pd.Series(valid_value, dtype="string") + else: + net.switch[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter, invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["element"], [*negativ_ints, *not_ints_list]), + itertools.product(["et"], [*strings, *not_strings_list]), # anything not in {"b","l","t","t3"} + itertools.product(["closed"], not_boolean_list), + itertools.product(["in_ka"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["z_ohm"], not_floats_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + + create_switch(net, bus=0, element=1, et="b", type="CB", closed=True) + + net.switch[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize("parameter", ["bus", "element", "et", "closed"]) + def test_required_fields_nan_invalid(self, parameter): + """NaN in required columns is invalid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_switch(net, bus=b0, element=b1, et="b", closed=True) + net.switch[parameter] = float(np.nan) + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSwitchOptionalFields: + """Tests for optional switch fields""" + + def test_all_optional_fields_valid(self): + """All optional fields set""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_switch(net, bus=b0, element=b1, et="b", type="CB", closed=False) + # Optional fields + net.switch["name"] = pd.Series(["SW-A"], dtype="string") + net.switch["type"] = pd.Series(["CB"], dtype="string") + net.switch["in_ka"] = 20.0 # gt(0) + net.switch["z_ohm"] = 0.01 # any float + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + # Row 1: name/type set, in_ka/z_ohm null + create_switch(net, bus=b0, element=b1, et="b", type="CB", closed=True) + net.switch["name"] = pd.Series(["S1"], dtype="string") + net.switch["type"] = pd.Series(["CB"], dtype="string") + net.switch["in_ka"] = [float(np.nan)] # nullable + net.switch["z_ohm"] = [float(np.nan)] # nullable + + # Row 2: all optionals null + create_switch(net, bus=b0, element=b1, et="b", closed=False) + net.switch["name"].iat[1] = pd.NA + net.switch["type"].iat[1] = pd.NA + + validate_network(net) + + @pytest.mark.parametrize( + "parameter, valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["type"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_switch(net, bus=b0, element=b1, et="b", closed=True) + + if parameter in {"name", "type"}: + net.switch[parameter] = pd.Series([valid_value], dtype="string") + else: + net.switch[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter, invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["type"], not_strings_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_switch(net, bus=b0, element=b1, et="b", closed=True) + net.switch[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSwitchForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """bus must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_switch(net, bus=b0, element=b1, et="b", closed=True) + + net.switch["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestSwitchResults: + """Tests for switch results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_switch_result_totals(self): + """Aggregated power and current results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_tcsc_elements.py b/pandapower/test/network_schema/elements/test_pandera_tcsc_elements.py new file mode 100644 index 0000000000..d823f65201 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_tcsc_elements.py @@ -0,0 +1,302 @@ +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_tcsc +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.network_schema.tools.helper import get_dtypes +from pandapower.network_schema.bus import bus_schema +from pandapower.test.network_schema.elements.helper import ( + strings, + all_floats, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints, + positiv_ints_plus_zero, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + all_ints, + negativ_ints, + not_ints_list, + negativ_floats, + positiv_floats, + all_allowed_floats, +) + +float_range = [x for x in all_allowed_floats if 90 <= x <= 180] +not_float_range = [x for x in all_floats if x < 90 or x > 180] +invalid_low_float_range = [x for x in all_floats if x < 90] +invalid_high_float_range = [x for x in all_floats if x > 180] + + +class TestTcscRequiredFields: + """Tests for required TCSC fields""" + + # david fragen wegen chain + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["from_bus"], positiv_ints_plus_zero), + itertools.product(["to_bus"], positiv_ints_plus_zero), + itertools.product(["x_l_ohm"], positiv_floats_plus_zero), + itertools.product(["x_cvar_ohm"], negativ_floats_plus_zero), + itertools.product(["set_p_to_mw"], all_allowed_floats), + itertools.product(["thyristor_firing_angle_degree"], float_range), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_bus(net, 0.4, index=42) + create_tcsc( + net, + from_bus=0, + to_bus=1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100, + controllable=True, + in_service=False, + ) + net.tcsc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["from_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["to_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["x_l_ohm"], [*negativ_floats, *not_floats_list]), + itertools.product(["x_cvar_ohm"], [*positiv_floats, *not_floats_list]), + itertools.product(["set_p_to_mw"], not_floats_list), + itertools.product(["thyristor_firing_angle_degree"], not_float_range), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: Invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_tcsc( + net, + from_bus=0, + to_bus=1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100, + controllable=True, + in_service=False, + ) + net.tcsc[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTcscOptionalFields: + """Tests for optional tcsc fields""" + + def test_empty_network_validation(self): + """Test: tcsc with every optional fields is valid""" + net = create_empty_network() + create_bus(net, 0.4) + create_bus(net, 0.4) + create_tcsc( + net, + from_bus=0, + to_bus=1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100, + controllable=True, + in_service=False, + name="lorem ipsum", + min_angle_degree=100.0, + max_angle_degree=110.6, + ) + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: TCSC with some optional fields (including nulls) is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) # index 0 + b1 = create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + name="bye world", + ) + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + min_angle_degree=100.0, + ) + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + max_angle_degree=90, + ) + net.tcsc["min_angle_degree"].at[0] = float(np.nan) + net.tcsc["max_angle_degree"].at[0] = float(np.nan) + net.tcsc["max_angle_degree"].at[1] = float(np.nan) + net.tcsc["min_angle_degree"].at[2] = float(np.nan) + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + # name accepts strings and pd.NA + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["min_angle_degree"], [90.0, 100.0, 150.0]), + itertools.product(["max_angle_degree"], [180.0, 150.0, 100.0]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + b0 = create_bus(net, 0.4) # index 0 + b1 = create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + ) + if parameter == "name": + net.tcsc[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.tcsc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["min_angle_degree"], [*invalid_low_float_range, *not_floats_list]), + itertools.product(["max_angle_degree"], [*invalid_high_float_range, *not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: Invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) # index 0 + b1 = create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + ) + net.tcsc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_min_less_equal_max_check_passes(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) # index 0 + b1 = create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + ) + net.tcsc["min_angle_degree"] = 100.0 + net.tcsc["max_angle_degree"] = 150.0 + validate_network(net) + + def test_min_greater_than_max_fails(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) # index 0 + b1 = create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + create_tcsc( + net, + from_bus=b0, + to_bus=b1, + x_l_ohm=0.0, + x_cvar_ohm=-0.1, + set_p_to_mw=0.0, + thyristor_firing_angle_degree=100.0, + controllable=True, + in_service=False, + ) + net.tcsc["min_angle_degree"] = 160.0 + net.tcsc["max_angle_degree"] = 150.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTcscResults: + """Tests for tcsc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_tcsc_voltage_results(self): + """Test: Voltage results are within valid range""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_tcsc_power_results(self): + """Test: Power results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_trafo3w_elements.py b/pandapower/test/network_schema/elements/test_pandera_trafo3w_elements.py new file mode 100644 index 0000000000..75d656dd56 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_trafo3w_elements.py @@ -0,0 +1,694 @@ +# test_pandera_trafo3w_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats, + negativ_floats_plus_zero, + all_allowed_floats, +) + +# Allowed/invalid categorical values for tap-related columns + +allowed_tap_side = ["hv", "mv", "lv"] +invalid_tap_side = [s for s in strings if s not in allowed_tap_side] +allowed_tap_changer_types = ["Ratio", "Symmetrical", "Ideal", "Tabular"] +invalid_tap_changer_types = [s for s in strings if s not in allowed_tap_changer_types] + + +class TestTrafo3wRequiredFields: + """Tests for required trafo3w fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["hv_bus", "mv_bus", "lv_bus"], positiv_ints_plus_zero), + itertools.product(["vn_hv_kv", "vn_mv_kv", "vn_lv_kv"], positiv_floats), + itertools.product(["sn_hv_mva", "sn_mv_mva", "sn_lv_mva"], positiv_floats), + itertools.product(["vk_hv_percent", "vk_mv_percent", "vk_lv_percent"], positiv_floats), + itertools.product(["vkr_hv_percent", "vkr_mv_percent", "vkr_lv_percent"], positiv_floats_plus_zero), + itertools.product(["pfe_kw", "i0_percent", "shift_mv_degree", "shift_lv_degree"], all_allowed_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 110.0) # index 0 (HV) + create_bus(net, 20.0) # index 1 (MV) + create_bus(net, 10.0) # index 2 (LV) + create_bus(net, 0.4, index=42) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + if parameter in {"vk_hv_percent", "vk_mv_percent", "vk_lv_percent"}: + net.trafo3w["vkr_hv_percent"] = 0.0 + net.trafo3w["vkr_mv_percent"] = 0.0 + net.trafo3w["vkr_lv_percent"] = 0.0 + + net.trafo3w[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["hv_bus", "mv_bus", "lv_bus"], [*negativ_ints, *not_ints_list]), + itertools.product( + [ + "vn_hv_kv", + "vn_mv_kv", + "vn_lv_kv", + "sn_hv_mva", + "sn_mv_mva", + "sn_lv_mva", + "vk_hv_percent", + "vk_mv_percent", + "vk_lv_percent", + ], + [*negativ_floats_plus_zero, *not_floats_list], + ), + itertools.product( + ["vkr_hv_percent", "vkr_mv_percent", "vkr_lv_percent"], [*negativ_floats, *not_floats_list] + ), + itertools.product(["pfe_kw", "i0_percent", "shift_mv_degree", "shift_lv_degree"], not_floats_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 110.0) # index 0 (HV) + create_bus(net, 20.0) # index 1 (MV) + create_bus(net, 10.0) # index 2 (LV) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + # Neutralize vkr all nulls, so group not triggered + net.trafo3w["tap_side"] = pd.Series([pd.NA], dtype="string") + net.trafo3w["tap_pos"] = np.nan + net.trafo3w["tap_neutral"] = np.nan + net.trafo3w["tap_changer_type"] = pd.Series([pd.NA], dtype="string") + + # TDT group left null + net.trafo3w["tap_dependency_table"] = pd.Series([pd.NA], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([pd.NA], dtype="Int64") + + # Other nullables + net.trafo3w["name"] = pd.Series([pd.NA], dtype="string") + net.trafo3w["std_type"] = pd.Series([pd.NA], dtype="string") + net.trafo3w["vector_group"] = pd.Series([pd.NA], dtype="string") + net.trafo3w["vkr0_x"] = np.nan + net.trafo3w["vk0_x"] = np.nan + net.trafo3w["max_loading_percent"] = np.nan + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["std_type"], strings), + itertools.product(["vector_group"], strings), + itertools.product(["tap_side"], allowed_tap_side), + itertools.product(["tap_changer_type"], allowed_tap_changer_types), + itertools.product(["tap_step_percent"], positiv_floats), + itertools.product(["tap_step_degree"], all_allowed_floats), + itertools.product(["tap_at_star_point"], bools), + itertools.product(["vkr0_x"], all_allowed_floats), + itertools.product(["vk0_x"], all_allowed_floats), + itertools.product(["max_loading_percent"], all_allowed_floats), + itertools.product(["id_characteristic_table"], positiv_ints_plus_zero), + itertools.product(["tap_dependency_table"], bools), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + create_bus(net, 0.4, index=42) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + # For TDT: ensure the counterpart exists when one is set + if parameter == "tap_dependency_table": + net.trafo3w["tap_dependency_table"] = pd.Series([valid_value], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([0], dtype="Int64") + elif parameter == "id_characteristic_table": + net.trafo3w["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([valid_value], dtype="Int64") + else: + net.trafo3w[parameter] = valid_value + + # Satisfy tap group when tap_side is used + if parameter == "tap_side": + net.trafo3w["tap_side"] = pd.Series([valid_value], dtype="string") + net.trafo3w["tap_pos"] = 1.0 + net.trafo3w["tap_neutral"] = 0.0 + + # Keep expected dtypes + if parameter in {"name", "std_type", "vector_group", "tap_side", "tap_changer_type"}: + net.trafo3w[parameter] = net.trafo3w[parameter].astype("string") + if parameter in {"tap_dependency_table", "tap_at_star_point"}: + net.trafo3w[parameter] = net.trafo3w[parameter].astype("boolean") + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["std_type"], not_strings_list), + itertools.product(["vector_group"], not_strings_list), + itertools.product(["tap_side"], invalid_tap_side), + itertools.product(["tap_changer_type"], invalid_tap_changer_types), + itertools.product(["tap_step_percent"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["tap_step_degree"], not_floats_list), + itertools.product(["tap_at_star_point"], not_boolean_list), + itertools.product(["max_loading_percent"], not_floats_list), + itertools.product(["tap_dependency_table"], not_boolean_list), + itertools.product(["id_characteristic_table"], [*negativ_ints, *not_ints_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + # Satisfy groups so only target parameter triggers failure + net.trafo3w["tap_pos"] = 1.0 + net.trafo3w["tap_neutral"] = 0.0 + net.trafo3w["tap_side"] = pd.Series(["hv"], dtype="string") + net.trafo3w["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([0], dtype="Int64") + + net.trafo3w[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTrafo3wDependencies: + """Tests for group dependencies and FK""" + + def test_tap_group_partial_missing_invalid(self): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + net.trafo3w["tap_side"] = pd.Series(["mv"], dtype="string") + net.trafo3w["tap_neutral"] = 0.0 + net.trafo3w["tap_pos"] = pd.NA # missing -> fail + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_tap_group_complete_valid(self): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + net.trafo3w["tap_side"] = pd.Series(["lv"], dtype="string") + net.trafo3w["tap_pos"] = 2.0 + net.trafo3w["tap_neutral"] = 0.0 + validate_network(net) + + def test_tdt_group_partial_missing_invalid(self): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + net.trafo3w["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([pd.NA], dtype="Int64") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_tdt_group_complete_valid(self): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + net.trafo3w["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo3w["id_characteristic_table"] = pd.Series([0], dtype="Int64") + validate_network(net) + + def test_invalid_bus_fk(self): + net = create_empty_network() + create_bus(net, 110.0) + create_bus(net, 20.0) + create_bus(net, 10.0) + + net.trafo3w = pd.DataFrame( + [ + { + "hv_bus": 0, + "mv_bus": 1, + "lv_bus": 2, + "vn_hv_kv": 110.0, + "vn_mv_kv": 20.0, + "vn_lv_kv": 10.0, + "sn_hv_mva": 63.0, + "sn_mv_mva": 25.0, + "sn_lv_mva": 25.0, + "vk_hv_percent": 10.0, + "vk_mv_percent": 8.0, + "vk_lv_percent": 6.0, + "vkr_hv_percent": 0.5, + "vkr_mv_percent": 0.4, + "vkr_lv_percent": 0.3, + "pfe_kw": 30.0, + "i0_percent": 0.1, + "shift_mv_degree": 0.0, + "shift_lv_degree": 0.0, + "in_service": True, + } + ] + ) + + net.trafo3w["hv_bus"] = 9999 # FK violation + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTrafo3wResults: + """Tests for trafo3w results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_trafo3w_power_flows(self): + """Test: Power flow result fields have valid numeric values""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_trafo3w_short_circuit_results(self): + """Test: Short-circuit result fields contain expected ranges""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_trafo_elements.py b/pandapower/test/network_schema/elements/test_pandera_trafo_elements.py new file mode 100644 index 0000000000..c599a26036 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_trafo_elements.py @@ -0,0 +1,416 @@ +# test_pandera_trafo_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_transformer +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + positiv_ints, + negativ_ints, + negativ_ints_plus_zero, + not_ints_list, + positiv_floats, + positiv_floats_plus_zero, + negativ_floats_plus_zero, + all_allowed_floats, + negativ_floats, +) + +# Common std_type available in pandapower + +STD_TYPE = "25 MVA 110/10 kV" + + +class TestTrafoRequiredFields: + """Tests for required trafo fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["hv_bus"], positiv_ints_plus_zero), + itertools.product(["lv_bus"], positiv_ints_plus_zero), + itertools.product(["sn_mva"], positiv_floats), + itertools.product(["vn_hv_kv"], positiv_floats), + itertools.product(["vn_lv_kv"], positiv_floats), + itertools.product(["vk_percent"], positiv_floats), + itertools.product(["vkr_percent"], positiv_floats_plus_zero), + itertools.product(["pfe_kw"], positiv_floats_plus_zero), + itertools.product(["i0_percent"], positiv_floats_plus_zero), + itertools.product(["parallel"], positiv_ints), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + net = create_empty_network() + create_bus(net, 110) # index 0 (HV) + create_bus(net, 10) # index 1 (LV) + create_bus(net, 0.4, index=42) + + create_transformer(net, hv_bus=0, lv_bus=1, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["hv_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["lv_bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["sn_mva"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vn_hv_kv"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vn_lv_kv"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vk_percent"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vkr_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["pfe_kw"], [*negativ_floats, *not_floats_list]), + itertools.product(["i0_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["parallel"], [*negativ_ints_plus_zero, *not_ints_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + net = create_empty_network() + create_bus(net, 110) + create_bus(net, 10) + + create_transformer(net, hv_bus=0, lv_bus=1, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTrafoOptionalFields: + """Tests for optional trafo fields and group dependencies (tap, tap2, tdt, opf)""" + + def test_all_optional_fields_valid(self): + """All optional fields set; tap/tap2/tdt groups complete; OPF present""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + # Text optionals + net.trafo["name"] = pd.Series(["Trafo A"], dtype="string") + net.trafo["std_type"] = pd.Series(["custom_type"], dtype="string") + net.trafo["vector_group"] = pd.Series(["Dyn5"], dtype="string") + net.trafo["tap_changer_type"] = pd.Series(["Ratio"], dtype="string") + net.trafo["tap2_changer_type"] = pd.Series(["Ideal"], dtype="string") + + # OPF single-column group + net.trafo["max_loading_percent"] = 100.0 + + # Other numerics + net.trafo["shift_degree"] = 0.0 + net.trafo["df"] = 0.8 + + # Zero sequence / SC optionals (accepted individually) + net.trafo["vk0_percent"] = 3.0 + net.trafo["vkr0_percent"] = 0.5 + net.trafo["mag0_percent"] = 60.0 + net.trafo["mag0_rx"] = 15.0 + net.trafo["si0_hv_partial"] = 0.5 + + # Tap group (complete) + net.trafo["tap_side"] = pd.Series(["hv"], dtype="string") + net.trafo["tap_neutral"] = 0.0 + net.trafo["tap_min"] = -5.0 + net.trafo["tap_max"] = 5.0 + net.trafo["tap_step_percent"] = 2.5 + net.trafo["tap_step_degree"] = 0.0 + net.trafo["tap_pos"] = 0.0 + + # Tap2 group (complete) + net.trafo["tap2_side"] = pd.Series(["lv"], dtype="string") + net.trafo["tap2_neutral"] = 0.0 + net.trafo["tap2_min"] = -3.0 + net.trafo["tap2_max"] = 3.0 + net.trafo["tap2_step_percent"] = 1.0 + net.trafo["tap2_step_degree"] = 0.0 + net.trafo["tap2_pos"] = 0.0 + + # TDT group (complete) + net.trafo["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo["id_characteristic_table"] = pd.Series([0], dtype="Int64") + + # Leakage ratios and SC extras + net.trafo["leakage_resistance_ratio_hv"] = 0.5 + net.trafo["leakage_reactance_ratio_hv"] = 0.5 + net.trafo["xn_ohm"] = 0.0 + net.trafo["pt_percent"] = 100.0 + + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Optionals with nulls; ensure groups not partially triggered""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + # Row 1: name/vector_group only + create_transformer( + net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1, max_loading_percent=80.0 + ) + net.trafo["name"] = pd.Series(["T1"], dtype="string") + net.trafo["vector_group"] = pd.Series(["Dyn5"], dtype="string") + + # Row 2: OPF present + create_transformer( + net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1, max_loading_percent=80 + ) + + # Row 3: tdt complete, others NA + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=False, parallel=1) + net.trafo["tap_dependency_table"] = pd.Series([pd.NA, True, pd.NA], dtype="boolean") + net.trafo["id_characteristic_table"] = pd.Series([pd.NA, 1, pd.NA], dtype="Int64") + net.trafo["std_type"] = pd.Series([pd.NA, pd.NA, pd.NA], dtype="string") + validate_network(net) + + def test_tap_group_partial_missing_invalid(self): + """Any tap column set -> all tap columns must be present""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["tap_pos"] = pd.Series([float("nan")], dtype="float") # partial -> invalid + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + # Another partial case + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + net.trafo["tap_side"] = pd.Series([pd.NA], dtype="string") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_tap2_group_partial_missing_invalid(self): + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["tap2_pos"] = 0.0 # partial -> invalid + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + net.trafo["tap2_side"] = pd.Series(["lv"], dtype="string") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_tdt_group_partial_missing_invalid(self): + """Tap dependency table group must be complete""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + net.trafo["tap_dependency_table"] = pd.Series([True], dtype="boolean") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + net.trafo["id_characteristic_table"] = pd.Series([pd.NA, 1], dtype="Int64") + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], strings), + itertools.product(["std_type"], strings), + itertools.product(["shift_degree"], all_allowed_floats), + itertools.product(["df"], [0.1, 0.5, 1.0]), + itertools.product(["vector_group"], strings), + itertools.product(["vk0_percent"], positiv_floats_plus_zero), + itertools.product(["vkr0_percent"], positiv_floats_plus_zero), + itertools.product(["mag0_percent"], positiv_floats_plus_zero), + itertools.product(["mag0_rx"], all_allowed_floats), + itertools.product(["si0_hv_partial"], positiv_floats_plus_zero), + itertools.product(["tap_changer_type"], ["Ratio", "Symmetrical", "Ideal", "Tabular"]), + itertools.product(["tap2_changer_type"], ["Ratio", "Symmetrical", "Ideal", "nan"]), + itertools.product(["leakage_resistance_ratio_hv"], [0.0, 0.5, 1.0]), + itertools.product(["leakage_reactance_ratio_hv"], [0.0, 0.5, 1.0]), + itertools.product(["xn_ohm"], all_allowed_floats), + itertools.product(["pt_percent"], all_allowed_floats), + itertools.product(["max_loading_percent"], positiv_floats_plus_zero), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Valid optional values accepted (groups satisfied)""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + # Satisfy tap/tap2/tdt groups to avoid dependency failures + net.trafo["tap_side"] = pd.Series(["hv"], dtype="string") + net.trafo["tap_neutral"] = 0.0 + net.trafo["tap_min"] = -5.0 + net.trafo["tap_max"] = 5.0 + net.trafo["tap_step_percent"] = 2.5 + net.trafo["tap_step_degree"] = 0.0 + net.trafo["tap_pos"] = 0.0 + + net.trafo["tap2_side"] = pd.Series(["lv"], dtype="string") + net.trafo["tap2_neutral"] = 0.0 + net.trafo["tap2_min"] = -3.0 + net.trafo["tap2_max"] = 3.0 + net.trafo["tap2_step_percent"] = 1.0 + net.trafo["tap2_step_degree"] = 0.0 + net.trafo["tap2_pos"] = 0.0 + + net.trafo["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo["id_characteristic_table"] = pd.Series([0], dtype="Int64") + + # Set target parameter + if parameter in {"name", "std_type", "vector_group", "tap_changer_type", "tap2_changer_type"}: + net.trafo[parameter] = pd.Series([valid_value], dtype="string") + else: + net.trafo[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["std_type"], not_strings_list), + itertools.product(["shift_degree"], not_floats_list), + itertools.product(["df"], [0.0, -0.1, 1.1, *not_floats_list]), + itertools.product(["vector_group"], not_strings_list), + itertools.product(["vk0_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["vkr0_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["mag0_percent"], [*negativ_floats, *not_floats_list]), + itertools.product(["mag0_rx"], not_floats_list), + itertools.product(["si0_hv_partial"], [*negativ_floats, *not_floats_list]), + itertools.product(["tap_changer_type"], ["bad_type", *not_strings_list]), + itertools.product(["tap2_changer_type"], ["bad_type", *not_strings_list]), + itertools.product(["leakage_resistance_ratio_hv"], [*negativ_floats, 1.1, *not_floats_list]), + itertools.product(["leakage_reactance_ratio_hv"], [*negativ_floats, 1.1, *not_floats_list]), + itertools.product(["xn_ohm"], not_floats_list), + itertools.product(["pt_percent"], not_floats_list), + itertools.product(["max_loading_percent"], [*not_floats_list]), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Invalid optional values rejected (groups satisfied)""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + # Satisfy tap/tap2/tdt groups + net.trafo["tap_side"] = pd.Series(["hv"], dtype="string") + net.trafo["tap_neutral"] = 0.0 + net.trafo["tap_min"] = -5.0 + net.trafo["tap_max"] = 5.0 + net.trafo["tap_step_percent"] = 2.5 + net.trafo["tap_step_degree"] = 0.0 + net.trafo["tap_pos"] = 0.0 + + net.trafo["tap2_side"] = pd.Series(["lv"], dtype="string") + net.trafo["tap2_neutral"] = 0.0 + net.trafo["tap2_min"] = -3.0 + net.trafo["tap2_max"] = 3.0 + net.trafo["tap2_step_percent"] = 1.0 + net.trafo["tap2_step_degree"] = 0.0 + net.trafo["tap2_pos"] = 0.0 + + net.trafo["tap_dependency_table"] = pd.Series([True], dtype="boolean") + net.trafo["id_characteristic_table"] = pd.Series([0], dtype="Int64") + + net.trafo[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_min_less_equal_max_check_passes(self): + """Schema check: min_angle_degree <= max_angle_degree""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["min_angle_degree"] = 0.0 + net.trafo["max_angle_degree"] = 10.0 + validate_network(net) + + def test_min_greater_than_max_fails(self): + """Schema check: min_angle_degree > max_angle_degree -> fail""" + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["min_angle_degree"] = 20.0 + net.trafo["max_angle_degree"] = 10.0 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTrafoForeignKey: + """Tests for FK constraints on hv_bus/lv_bus""" + + def test_invalid_hv_bus_index(self): + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["hv_bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_invalid_lv_bus_index(self): + net = create_empty_network() + b_hv = create_bus(net, 110) + b_lv = create_bus(net, 10) + create_transformer(net, hv_bus=b_hv, lv_bus=b_lv, std_type=STD_TYPE, in_service=True, parallel=1) + + net.trafo["lv_bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestTrafoResults: + """Tests for trafo results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_trafo_result_totals(self): + """Aggregated p/q and loading results are consistent""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_vsc_bipolar_elements.py b/pandapower/test/network_schema/elements/test_pandera_vsc_bipolar_elements.py new file mode 100644 index 0000000000..6f6a0d960b --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_vsc_bipolar_elements.py @@ -0,0 +1,271 @@ +# test_pandera_vsc_bipolar_elements.py + +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_bus_dc, create_vsc_bipolar +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +def _create_valid_vsc_bipolar_row(required=True, bus=0, bus_dc_plus=0, bus_dc_minus=1): + df = { + "bus": bus, + "bus_dc_plus": bus_dc_plus, + "bus_dc_minus": bus_dc_minus, + "r_ohm": 0.1, + "x_ohm": 0.05, + "r_dc_ohm": 0.02, + "pl_dc_mw": 0.5, + "control_mode": "Vac_phi", + "control_value_1": 1.0, + "control_value_2": 10.0, + "controllable": True, + "in_service": True, + } + if not required: + df["name"] = "test" + return df + + +class TestVscBipolarRequiredFields: + """Tests for required vsc_bipolar fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["bus_dc_plus"], positiv_ints_plus_zero), + itertools.product(["bus_dc_minus"], positiv_ints_plus_zero), + itertools.product(["r_ohm"], all_allowed_floats), + itertools.product(["x_ohm"], all_allowed_floats), + itertools.product(["r_dc_ohm"], all_allowed_floats), + itertools.product(["pl_dc_mw"], all_allowed_floats), + itertools.product(["control_mode"], ["Vac_phi", "Vdc_phi", "Vdc_Q", "Pac_Vac", "Pac_Qac", "Vdc_Vac"]), + itertools.product(["control_value_1"], all_allowed_floats), + itertools.product(["control_value_2"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_bus_dc(net, vn_kv=110.0, index=42) + + # Create a valid vsc_bipolar element + row = _create_valid_vsc_bipolar_row(bus=0, bus_dc_plus=0, bus_dc_minus=1) + create_vsc_bipolar( + net=net, + bus=row["bus"], + bus_dc_plus=row["bus_dc_plus"], + bus_dc_minus=row["bus_dc_minus"], + r_ohm=row["r_ohm"], + x_ohm=row["x_ohm"], + r_dc_ohm=row["r_dc_ohm"], + pl_dc_mw=row["pl_dc_mw"], + control_mode=row["control_mode"], + control_value_1=row["control_value_1"], + control_value_2=row["control_value_2"], + controllable=row["controllable"], + in_service=row["in_service"], + ) + net.vsc_bipolar[parameter] = valid_value + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["bus_dc_plus"], [*negativ_ints, *not_ints_list]), + itertools.product(["bus_dc_minus"], [*negativ_ints, *not_ints_list]), + itertools.product(["r_ohm"], not_floats_list), + itertools.product(["x_ohm"], not_floats_list), + itertools.product(["r_dc_ohm"], not_floats_list), + itertools.product(["pl_dc_mw"], not_floats_list), + itertools.product( + ["control_mode"], [*strings, *not_strings_list] + ), # TODO: check if string definition is good + itertools.product(["control_value_2"], not_floats_list), + itertools.product(["control_value_1"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + + row = _create_valid_vsc_bipolar_row(bus=0, bus_dc_plus=0, bus_dc_minus=1) + create_vsc_bipolar( + net=net, + bus=row["bus"], + bus_dc_plus=row["bus_dc_plus"], + bus_dc_minus=row["bus_dc_minus"], + r_ohm=row["r_ohm"], + x_ohm=row["x_ohm"], + r_dc_ohm=row["r_dc_ohm"], + pl_dc_mw=row["pl_dc_mw"], + control_mode=row["control_mode"], + control_value_1=row["control_value_1"], + control_value_2=row["control_value_2"], + controllable=row["controllable"], + in_service=row["in_service"], + ) + net.vsc_bipolar[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscBipolarOptionalFields: + """Tests for optional vsc_bipolar fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + + row = _create_valid_vsc_bipolar_row(required=False, bus=b0, bus_dc_plus=0, bus_dc_minus=1) + create_vsc_bipolar( + net=net, + bus=row["bus"], + bus_dc_plus=row["bus_dc_plus"], + bus_dc_minus=row["bus_dc_minus"], + r_ohm=row["r_ohm"], + x_ohm=row["x_ohm"], + r_dc_ohm=row["r_dc_ohm"], + pl_dc_mw=row["pl_dc_mw"], + control_mode=row["control_mode"], + control_value_1=row["control_value_1"], + control_value_2=row["control_value_2"], + controllable=row["controllable"], + in_service=row["in_service"], + ) + from pandapower.create._utils import add_column_to_df + + add_column_to_df(net, "vsc_bipolar", parameter) + net.vsc_bipolar["name"].name = parameter + + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + + row = _create_valid_vsc_bipolar_row(required=False, bus=b0, bus_dc_plus=0, bus_dc_minus=1) + create_vsc_bipolar( + net=net, + bus=row["bus"], + bus_dc_plus=row["bus_dc_plus"], + bus_dc_minus=row["bus_dc_minus"], + r_ohm=row["r_ohm"], + x_ohm=row["x_ohm"], + r_dc_ohm=row["r_dc_ohm"], + pl_dc_mw=row["pl_dc_mw"], + control_mode=row["control_mode"], + control_value_1=row["control_value_1"], + control_value_2=row["control_value_2"], + controllable=row["controllable"], + in_service=row["in_service"], + ) + net.vsc_bipolar[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscBipolarForeignKey: + """Tests for foreign key constraints""" + + @pytest.mark.parametrize("fk_field", ["bus", "bus_dc_plus", "bus_dc_minus"]) + def test_invalid_fk_index(self, fk_field): + """Test: bus and bus_dc FKs must reference existing indices""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + create_bus(net, 0.4) + + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + + row = _create_valid_vsc_bipolar_row(bus=b0, bus_dc_plus=0, bus_dc_minus=1) + create_vsc_bipolar( + net=net, + bus=row["bus"], + bus_dc_plus=row["bus_dc_plus"], + bus_dc_minus=row["bus_dc_minus"], + r_ohm=row["r_ohm"], + x_ohm=row["x_ohm"], + r_dc_ohm=row["r_dc_ohm"], + pl_dc_mw=row["pl_dc_mw"], + control_mode=row["control_mode"], + control_value_1=row["control_value_1"], + control_value_2=row["control_value_2"], + controllable=row["controllable"], + in_service=row["in_service"], + ) + net.vsc_bipolar[fk_field] = 9999 # invalid references + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscBipolarResults: + """Tests for vsc_bipolar results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_bipolar_result_totals(self): + """Test: aggregated p_mw / q_mvar / dc results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_bipolar_ac_dc_results(self): + """Test: AC and DC side results contain valid values""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_vsc_elements.py b/pandapower/test/network_schema/elements/test_pandera_vsc_elements.py new file mode 100644 index 0000000000..106b03982a --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_vsc_elements.py @@ -0,0 +1,333 @@ +# test_pandera_vsc_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_bus_dc, create_vsc +from pandapower.network_schema.tools.validation.network_validation import validate_network +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats_plus_zero, + all_allowed_floats, +) + +# Allowed/invalid categorical values + +allowed_control_mode_ac = ["vm_pu", "q_mvar", "slack"] +invalid_control_mode_ac = [s for s in strings if s not in allowed_control_mode_ac] + +allowed_control_mode_dc = ["vm_pu", "p_mw"] +invalid_control_mode_dc = [s for s in strings if s not in allowed_control_mode_dc] + + +class TestVscRequiredFields: + """Tests for required VSC fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["bus_dc"], positiv_ints_plus_zero), + itertools.product(["r_ohm"], positiv_floats_plus_zero), + itertools.product(["x_ohm"], positiv_floats_plus_zero), + itertools.product(["r_dc_ohm"], all_allowed_floats), + itertools.product(["pl_dc_mw"], all_allowed_floats), + itertools.product(["control_mode_ac"], allowed_control_mode_ac), + itertools.product(["control_value_ac"], all_allowed_floats), + itertools.product(["control_mode_dc"], allowed_control_mode_dc), + itertools.product(["control_value_dc"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + # AC buses + create_bus(net, vn_kv=110.0) # index 0 + create_bus(net, vn_kv=20.0) # index 1 + create_bus(net, vn_kv=0.4, index=42) + # DC buses + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # index 0 + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # index 1 + create_bus_dc(net, vm_pu=1.0, index=42, vn_kv=110.0) + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + name="VSC-1", + ) + + net.vsc[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["bus_dc"], [*negativ_ints, *not_ints_list]), + itertools.product(["r_ohm"], [*not_floats_list, -0.1]), + itertools.product(["x_ohm"], [*not_floats_list, -0.1]), + itertools.product(["r_dc_ohm"], not_floats_list), + itertools.product(["pl_dc_mw"], not_floats_list), + itertools.product(["control_mode_ac"], [*invalid_control_mode_ac, *not_strings_list]), + itertools.product(["control_value_ac"], not_floats_list), + itertools.product(["control_mode_dc"], [*invalid_control_mode_dc, *not_strings_list]), + itertools.product(["control_value_dc"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + # AC buses + create_bus(net, vn_kv=110.0) # index 0 + create_bus(net, vn_kv=20.0) # index 1 + # DC buses + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # index 0 + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # index 1 + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + ) + + net.vsc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscOptionalFields: + """Tests for optional VSC fields""" + + def test_all_optional_fields_valid(self): + """Test: VSC with optional 'name' set is valid""" + net = create_empty_network() + create_bus(net, vn_kv=110.0) # AC + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # DC + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.3, + control_mode_ac="q_mvar", + control_value_ac=10.0, + control_mode_dc="p_mw", + control_value_dc=5.0, + controllable=False, + in_service=True, + name="Alpha", + ) + net.vsc["name"] = net.vsc["name"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: VSC with optional 'name' including nulls is valid""" + net = create_empty_network() + # AC/DC buses + create_bus(net, vn_kv=20.0) # 0 + create_bus(net, vn_kv=10.0) # 1 + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # 0 + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) # 1 + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=False, + name="hello", + ) + create_vsc( + net, + bus=1, + bus_dc=1, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="slack", + control_value_ac=0.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=False, + in_service=True, + name=None, + ) + + net.vsc["name"] = pd.Series(["V1", pd.NA], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list(itertools.product(["name"], [pd.NA, *strings])), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + create_bus(net, vn_kv=110.0) + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + ) + net.vsc[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list(itertools.product(["name"], not_strings_list)), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: Invalid optional values are rejected""" + net = create_empty_network() + create_bus(net, vn_kv=110.0) + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + ) + net.vsc[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + net = create_empty_network() + create_bus(net, vn_kv=110.0) + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + ) + + net.vsc["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_invalid_bus_dc_index(self): + net = create_empty_network() + create_bus(net, vn_kv=110.0) + create_bus_dc(net, vm_pu=1.0, vn_kv=110.0) + + create_vsc( + net, + bus=0, + bus_dc=0, + r_ohm=0.0, + x_ohm=0.0, + r_dc_ohm=0.0, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="vm_pu", + control_value_dc=1.0, + controllable=True, + in_service=True, + ) + + net.vsc["bus_dc"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVscResults: + """Tests for vsc results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_power_results(self): + """Test: Power results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_voltages(self): + """Test: AC/DC voltages are within valid ranges""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_vsc_stacked_elements.py b/pandapower/test/network_schema/elements/test_pandera_vsc_stacked_elements.py new file mode 100644 index 0000000000..7016c1ffbd --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_vsc_stacked_elements.py @@ -0,0 +1,296 @@ +import itertools +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_bus_dc, create_vsc_stacked +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestVSCSTACKEDRequiredFields: + """Tests for required vsc_stacked fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["bus_dc_plus"], positiv_ints_plus_zero), + itertools.product(["bus_dc_minus"], positiv_ints_plus_zero), + itertools.product(["r_ohm"], all_allowed_floats), + itertools.product(["x_ohm"], all_allowed_floats), + itertools.product(["r_dc_ohm"], all_allowed_floats), + itertools.product(["pl_dc_mw"], all_allowed_floats), + itertools.product(["control_mode_ac"], ["vm_pu", "q_mvar", "slack"]), + itertools.product(["control_value_ac"], all_allowed_floats), + itertools.product(["control_mode_dc"], ["vm_pu", "p_mw"]), + itertools.product(["control_value_dc"], all_allowed_floats), + itertools.product(["controllable"], bools), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, vn_kv=110.0) # index 0 + create_bus(net, vn_kv=110.0) # index 1 + create_bus(net, vn_kv=110.0, index=42) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_bus_dc(net, vn_kv=110.0, index=42) + + create_vsc_stacked( + net, + bus=0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + name="test", + ) + + net.vsc_stacked[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["bus_dc_plus"], [*negativ_ints, *not_ints_list]), + itertools.product(["bus_dc_minus"], [*negativ_ints, *not_ints_list]), + itertools.product(["r_ohm"], not_floats_list), + itertools.product(["x_ohm"], not_floats_list), + itertools.product(["r_dc_ohm"], not_floats_list), + itertools.product(["pl_dc_mw"], not_floats_list), + itertools.product(["control_mode_ac"], [*strings, *not_strings_list]), + itertools.product(["control_value_ac"], not_floats_list), + itertools.product(["control_mode_dc"], [*strings, *not_strings_list]), + itertools.product(["control_value_dc"], not_floats_list), + itertools.product(["controllable"], not_boolean_list), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 110.0) # index 0 + create_bus(net, 110.0) # index 1 + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_bus_dc(net, vn_kv=110.0, index=42) + create_vsc_stacked( + net, + bus=0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + ) + + net.vsc_stacked[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVSCSTACKEDOptionalFields: + """Tests for optional vsc_stacked fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 110.0) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_vsc_stacked( + net, + bus=b0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + name="initial", + ) + + net.vsc_stacked[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 110.0) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_vsc_stacked( + net, + bus=b0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + ) + + net.vsc_stacked[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVSCSTACKEDSchemaForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + """Test: bus FK must reference an existing bus index""" + net = create_empty_network() + b0 = create_bus(net, 110.0) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_vsc_stacked( + net, + bus=b0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + ) + + net.vsc_stacked["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_invalid_bus_dc_plus_index(self): + """Test: bus_dc_plus FK must reference an existing dc bus index""" + net = create_empty_network() + b0 = create_bus(net, 110.0) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_vsc_stacked( + net, + bus=b0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + ) + + net.vsc_stacked["bus_dc_plus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + def test_invalid_bus_dc_minus_index(self): + """Test: bus_dc_minus FK must reference an existing dc bus index""" + net = create_empty_network() + b0 = create_bus(net, 110.0) + create_bus_dc(net, vn_kv=110.0) # index 0 + create_bus_dc(net, vn_kv=110.0) # index 1 + create_vsc_stacked( + net, + bus=b0, + bus_dc_plus=0, + bus_dc_minus=1, + r_ohm=0.1, + x_ohm=0.2, + r_dc_ohm=0.05, + pl_dc_mw=0.0, + control_mode_ac="vm_pu", + control_value_ac=1.0, + control_mode_dc="p_mw", + control_value_dc=0.0, + controllable=True, + in_service=True, + ) + + net.vsc_stacked["bus_dc_minus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestVCSSTACKEDResults: + """Tests for vsc_stacked results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_stacked_result_totals(self): + """Test: aggregated p_mw / q_mvar results are consistent""" + pass + + @pytest.mark.skip(reason="Not yet implemented") + def test_vsc_stacked_internal_results(self): + """Test: internal vm/va and dc quantities are within expected ranges""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_ward_elements.py b/pandapower/test/network_schema/elements/test_pandera_ward_elements.py new file mode 100644 index 0000000000..93f79e0637 --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_ward_elements.py @@ -0,0 +1,153 @@ +# test_pandera_ward_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_ward +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + not_ints_list, + negativ_ints, + all_allowed_floats, +) + + +class TestWardRequiredFields: + """Tests for required ward fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["ps_mw"], all_allowed_floats), + itertools.product(["qs_mvar"], all_allowed_floats), + itertools.product(["pz_mw"], all_allowed_floats), + itertools.product(["qz_mvar"], all_allowed_floats), + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) # ensure 42 exists for FK-positive tests + + create_ward(net, bus=0, ps_mw=1.0, qs_mvar=0.5, pz_mw=0.1, qz_mvar=0.05, in_service=True, name="w1") + + net.ward[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["ps_mw"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["qs_mvar"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["pz_mw"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["qz_mvar"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["in_service"], [float(np.nan), pd.NA, *not_boolean_list]), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + + create_ward(net, bus=0, ps_mw=1.0, qs_mvar=0.5, pz_mw=0.1, qz_mvar=0.05, in_service=True) + + net.ward[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestWardOptionalFields: + """Tests for optional ward fields""" + + def test_all_optional_fields_valid(self): + """Test: ward with optional 'name' set is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ward(net, bus=b0, ps_mw=1.0, qs_mvar=0.2, pz_mw=0.1, qz_mvar=0.05, in_service=True, name="Ward A") + # Ensure string dtype as per schema + net.ward["name"] = net.ward["name"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: ward with optional 'name' including nulls is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_ward(net, bus=b0, ps_mw=0.8, qs_mvar=0.1, pz_mw=0.0, qz_mvar=0.0, in_service=True, name="alpha") + create_ward(net, bus=b1, ps_mw=1.1, qs_mvar=0.3, pz_mw=0.2, qz_mvar=0.1, in_service=False, name=None) + + net.ward["name"] = pd.Series(["w1", pd.NA], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list(itertools.product(["name"], [pd.NA, *strings])), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ward(net, bus=b0, ps_mw=1.0, qs_mvar=0.2, pz_mw=0.1, qz_mvar=0.05, in_service=True) + net.ward[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list(itertools.product(["name"], not_strings_list)), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ward(net, bus=b0, ps_mw=1.0, qs_mvar=0.2, pz_mw=0.1, qz_mvar=0.05, in_service=True) + net.ward[parameter] = invalid_value + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestWardForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_ward(net, bus=b0, ps_mw=1.0, qs_mvar=0.2, pz_mw=0.1, qz_mvar=0.05, in_service=True) + net.ward["bus"] = 9999 + + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestWardResults: + """Tests for ward results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_ward_result_pq(self): + """Test: p_mw / q_mvar results are present and numeric""" + pass diff --git a/pandapower/test/network_schema/elements/test_pandera_xward_elements.py b/pandapower/test/network_schema/elements/test_pandera_xward_elements.py new file mode 100644 index 0000000000..387ac915dd --- /dev/null +++ b/pandapower/test/network_schema/elements/test_pandera_xward_elements.py @@ -0,0 +1,273 @@ +# test_pandera_xward_elements.py + +import itertools +import numpy as np +import pandas as pd +import pandera as pa +import pytest + +from pandapower.create import create_empty_network, create_bus, create_xward +from pandapower.network_schema.tools.validation.network_validation import validate_network + +from pandapower.test.network_schema.elements.helper import ( + strings, + bools, + not_strings_list, + not_floats_list, + not_boolean_list, + positiv_ints_plus_zero, + negativ_ints, + not_ints_list, + positiv_floats, + negativ_floats_plus_zero, + all_allowed_floats, +) + + +class TestXWardRequiredFields: + """Tests for required xward fields""" + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["bus"], positiv_ints_plus_zero), + itertools.product(["ps_mw"], all_allowed_floats), + itertools.product(["qs_mvar"], all_allowed_floats), + itertools.product(["pz_mw"], all_allowed_floats), + itertools.product(["qz_mvar"], all_allowed_floats), + itertools.product(["r_ohm"], positiv_floats), # gt(0) + itertools.product(["x_ohm"], positiv_floats), # gt(0) + itertools.product(["vm_pu"], positiv_floats), # gt(0) + itertools.product(["in_service"], bools), + ) + ), + ) + def test_valid_required_values(self, parameter, valid_value): + """Test: valid required values are accepted""" + net = create_empty_network() + create_bus(net, 0.4) # index 0 + create_bus(net, 0.4) # index 1 + create_bus(net, 0.4, index=42) + + create_xward( + net, + bus=0, + ps_mw=1.0, + qs_mvar=0.5, + pz_mw=0.1, + qz_mvar=0.05, + r_ohm=0.01, + x_ohm=0.02, + vm_pu=1.0, + in_service=True, + name="xw1", + slack_weight=1.0, + ) + + net.xward[parameter] = valid_value + # ensure string dtype for name when touched + if parameter == "name": + net.xward["name"] = net.xward["name"].astype("string") + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["bus"], [*negativ_ints, *not_ints_list]), + itertools.product(["ps_mw"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["qs_mvar"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["pz_mw"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["qz_mvar"], [float(np.nan), pd.NA, *not_floats_list]), + itertools.product(["r_ohm"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["x_ohm"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["vm_pu"], [*negativ_floats_plus_zero, *not_floats_list]), + itertools.product(["in_service"], not_boolean_list), + ) + ), + ) + def test_invalid_required_values(self, parameter, invalid_value): + """Test: invalid required values are rejected""" + net = create_empty_network() + create_bus(net, 0.4) # 0 + create_bus(net, 0.4) # 1 + + create_xward( + net, + bus=0, + ps_mw=1.0, + qs_mvar=0.5, + pz_mw=0.1, + qz_mvar=0.05, + r_ohm=0.01, + x_ohm=0.02, + vm_pu=1.0, + in_service=True, + ) + + net.xward[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestXWardOptionalFields: + """Tests for optional xward fields""" + + def test_all_optional_fields_valid(self): + """Test: xward with optional fields set is valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_xward( + net, + bus=b0, + ps_mw=0.8, + qs_mvar=0.2, + pz_mw=0.1, + qz_mvar=0.05, + r_ohm=0.02, + x_ohm=0.03, + vm_pu=1.01, + slack_weight=0.5, + in_service=True, + name="XWard A", + ) + net.xward["name"] = net.xward["name"].astype("string") + validate_network(net) + + def test_optional_fields_with_nulls(self): + """Test: optional fields including nulls are valid""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + b1 = create_bus(net, 0.4) + + create_xward( + net, + bus=b0, + ps_mw=1.0, + qs_mvar=0.3, + pz_mw=0.2, + qz_mvar=0.1, + r_ohm=0.05, + x_ohm=0.07, + vm_pu=1.0, + in_service=True, + name="alpha", + slack_weight=None, + ) + create_xward( + net, + bus=b1, + ps_mw=0.5, + qs_mvar=0.1, + pz_mw=0.0, + qz_mvar=0.0, + r_ohm=0.02, + x_ohm=0.03, + vm_pu=1.02, + in_service=False, + name=None, + slack_weight=None, + ) + + net.xward["name"] = pd.Series(["x1", pd.NA], dtype=pd.StringDtype()) + validate_network(net) + + @pytest.mark.parametrize( + "parameter,valid_value", + list( + itertools.chain( + itertools.product(["name"], [pd.NA, *strings]), + itertools.product(["slack_weight"], [*all_allowed_floats, float(np.nan)]), + ) + ), + ) + def test_valid_optional_values(self, parameter, valid_value): + """Test: valid optional values are accepted""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_xward( + net, + bus=b0, + ps_mw=1.0, + qs_mvar=0.3, + pz_mw=0.2, + qz_mvar=0.1, + r_ohm=0.02, + x_ohm=0.03, + vm_pu=1.0, + in_service=True, + ) + + if parameter == "name": + net.xward[parameter] = pd.Series([valid_value], dtype=pd.StringDtype()) + else: + net.xward[parameter] = valid_value + validate_network(net) + + @pytest.mark.parametrize( + "parameter,invalid_value", + list( + itertools.chain( + itertools.product(["name"], not_strings_list), + itertools.product(["slack_weight"], not_floats_list), + ) + ), + ) + def test_invalid_optional_values(self, parameter, invalid_value): + """Test: invalid optional values are rejected""" + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_xward( + net, + bus=b0, + ps_mw=1.0, + qs_mvar=0.3, + pz_mw=0.2, + qz_mvar=0.1, + r_ohm=0.02, + x_ohm=0.03, + vm_pu=1.0, + in_service=True, + ) + + net.xward[parameter] = invalid_value + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestXWardForeignKey: + """Tests for foreign key constraints""" + + def test_invalid_bus_index(self): + net = create_empty_network() + b0 = create_bus(net, 0.4) + + create_xward( + net, + bus=b0, + ps_mw=1.0, + qs_mvar=0.3, + pz_mw=0.2, + qz_mvar=0.1, + r_ohm=0.02, + x_ohm=0.03, + vm_pu=1.0, + in_service=True, + ) + + net.xward["bus"] = 9999 + with pytest.raises(pa.errors.SchemaError): + validate_network(net) + + +class TestXWardResults: + """Tests for xward results after calculations""" + + @pytest.mark.skip(reason="Not yet implemented") + def test_xward_result_pq(self): + """Test: p_mw / q_mvar results are present and numeric""" + pass diff --git a/pandapower/test/network_schema/tools/__init__.py b/pandapower/test/network_schema/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pandapower/test/network_schema/tools/test_pandera_bus_index_validation.py b/pandapower/test/network_schema/tools/test_pandera_bus_index_validation.py new file mode 100644 index 0000000000..e151d4aaf4 --- /dev/null +++ b/pandapower/test/network_schema/tools/test_pandera_bus_index_validation.py @@ -0,0 +1 @@ +# TODO: diff --git a/pandapower/test/network_schema/tools/test_pandera_group_dependency.py b/pandapower/test/network_schema/tools/test_pandera_group_dependency.py new file mode 100644 index 0000000000..5a6f8b7a74 --- /dev/null +++ b/pandapower/test/network_schema/tools/test_pandera_group_dependency.py @@ -0,0 +1,308 @@ +import pandas as pd +import pandera.pandas as pa +import pytest +import numpy as np + +from pandapower.network_schema.tools.validation.group_dependency import ( + create_column_group_dependency_validation_func, + create_column_dependency_checks_from_metadata, +) + + +class TestCreateColumnGroupDependencyValidationFunc: + """Tests for create_column_group_dependency_validation_func function.""" + + def test_all_columns_present_returns_true(self): + """Test that validator returns True when all specified columns are present.""" + validator = create_column_group_dependency_validation_func(["lat", "lon", "altitude"]) + df = pd.DataFrame({"lat": [1], "lon": [2], "altitude": [3], "other": [4]}) + + assert validator(df) is True + + def test_no_columns_present_returns_true(self): + """Test that validator returns True when none of the specified columns are present.""" + validator = create_column_group_dependency_validation_func(["lat", "lon", "altitude"]) + df = pd.DataFrame({"name": ["John"], "age": [25]}) + + assert validator(df) is True + + def test_partial_columns_present_returns_false(self): + """Test that validator returns False when only some specified columns are present.""" + validator = create_column_group_dependency_validation_func(["lat", "lon", "altitude"]) + df = pd.DataFrame({"lat": [1], "lon": [2], "name": ["John"]}) + + assert validator(df) is False + + def test_single_column_present_returns_false(self): + """Test that validator returns False when only one of multiple specified columns is present.""" + validator = create_column_group_dependency_validation_func(["lat", "lon", "altitude"]) + df = pd.DataFrame({"lat": [1], "name": ["John"]}) + + assert validator(df) is False + + def test_empty_column_list(self): + """Test validator behavior with empty column list.""" + validator = create_column_group_dependency_validation_func([]) + df = pd.DataFrame({"name": ["John"], "age": [25]}) + + assert validator(df) is True + + def test_single_column_dependency(self): + """Test validator with single column dependency.""" + validator = create_column_group_dependency_validation_func(["lat"]) + + # Column present + df_present = pd.DataFrame({"lat": [1], "other": [2]}) + assert validator(df_present) is True + + # Column absent (this is ok since a single column dependency does not exist) + df_absent = pd.DataFrame({"other": [2]}) + assert validator(df_absent) is True + + def test_duplicate_columns_in_input(self): + """Test that duplicate column names in input are handled correctly.""" + validator = create_column_group_dependency_validation_func(["lat", "lon", "lat"]) + + # All unique columns present + df = pd.DataFrame({"lat": [1], "lon": [2], "other": [3]}) + assert validator(df) is True + + # Only one column present + df_partial = pd.DataFrame({"lat": [1], "other": [3]}) + assert validator(df_partial) is False + + def test_empty_dataframe(self): + """Test validator with empty DataFrame.""" + validator = create_column_group_dependency_validation_func(["lat", "lon"]) + df = pd.DataFrame() + + assert validator(df) is True + + def test_case_sensitive_column_names(self): + """Test that column name matching is case sensitive.""" + validator = create_column_group_dependency_validation_func(["Lat", "Lon"]) + df = pd.DataFrame({"lat": [1], "lon": [2]}) # lowercase + + assert validator(df) is True # None of the specified columns present + + def test_partial_column_existence(self): + """Test that validation fails when only some columns from the group are present.""" + validator = create_column_group_dependency_validation_func(["Lat", "Lon"]) + + # Only 'Lat' present, 'Lon' missing + df_partial_1 = pd.DataFrame({"Lat": [1], "other_col": [2]}) + + assert validator(df_partial_1) is False # Partial presence should fail + + # Only 'Lon' present, 'Lat' missing + df_partial_2 = pd.DataFrame({"Lon": [1], "other_col": [2]}) + + assert validator(df_partial_2) is False # Partial presence should fail + + +class TestCreateColumnDependencyChecksFromMetadata: + """Tests for create_column_dependency_checks_from_metadata function.""" + + def test_single_dependency_group(self): + """Test creating checks for a single dependency group.""" + names = ["required_group"] + schema_columns = { + "col1": pa.Column(metadata={"required_group": True}), + "col2": pa.Column(metadata={"required_group": True}), + "col3": pa.Column(metadata={"other_group": True}), + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 1 # Only col1 and col2 have required_group + assert all(isinstance(check, pa.Check) for check in checks) + + def test_multiple_dependency_groups(self): + """Test creating checks for multiple dependency groups.""" + names = ["required_group", "optional_group"] + schema_columns = { + "col1": pa.Column(metadata={"required_group": True}), + "col2": pa.Column(metadata={"required_group": True}), + "col3": pa.Column(metadata={"optional_group": True}), + "col4": pa.Column(metadata={"other_group": True}), + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 2 + + def test_no_matching_dependencies(self): + """Test when no columns have the specified dependencies.""" + names = ["nonexistent_group"] + schema_columns = { + "col1": pa.Column(metadata={"other_group": True}), + "col2": pa.Column(metadata={"another_group": True}), + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 0 + + def test_empty_names_list(self): + """Test with empty dependency names list.""" + names = [] + schema_columns = { + "col1": pa.Column(metadata={"required_group": True}), + "col2": pa.Column(metadata={"optional_group": True}), + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 0 + + def test_empty_schema_columns(self): + """Test with empty schema columns dictionary.""" + names = ["required_group"] + schema_columns = {} + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 0 + + def test_columns_without_metadata(self): + """Test columns that have no metadata attribute.""" + names = ["required_group"] + schema_columns = { + "col1": pa.Column(metadata=None), + "col2": pa.Column(metadata={"required_group": True}), + "col3": pa.Column(), # pa.Column without metadata attribute + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 1 # Only col2 has valid metadata + + def test_dependency_with_false_value(self): + """Test that dependencies with False values are not included.""" + names = ["required_group"] + schema_columns = { + "col1": pa.Column(metadata={"required_group": True}), + "col2": pa.Column(metadata={"required_group_false": False}), + "col3": pa.Column(metadata={"required_group": True}), + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 1 # Only col1 and col3 + + def test_check_error_messages(self): + """Test that generated checks have correct error messages.""" + names = ["test_group"] + schema_columns = {"col1": pa.Column(metadata={"test_group": True})} + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 1 + expected_error = "test_group columns have dependency violations. Please ensure columns ['col1'] are present in the dataframe." + assert checks[0].error == expected_error + + def test_mixed_metadata_types(self): + """Test handling of different metadata value types.""" + names = ["test_group"] + schema_columns = { + "col1": pa.Column(metadata={"test_group": True}), + "col2": pa.Column(metadata={"test_group": 1}), # Truthy value + "col3": pa.Column(metadata={"test_group_false": 0}), # Falsy value + "col4": pa.Column(metadata={"test_group": "yes"}), # Truthy string + "col5": pa.Column(metadata={"test_group_false": ""}), # Falsy string + } + + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + assert len(checks) == 1 # col1, col2, col4 have truthy values + + +class TestIntegration: + """Integration tests combining both functions.""" + + def test_full_workflow_valid_dependencies(self): + """Test complete workflow with valid column dependencies.""" + # Setup schema with dependencies + names = ["location_group"] + schema_columns = { + "lat": pa.Column(nullable=True, metadata={"location_group": True}), + "lon": pa.Column(nullable=True, metadata={"location_group": True}), + "name": pa.Column(nullable=True, metadata={"other_group": True}), + } + + # Create checks + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + # Create schema + full_workflow_valid_schema = pa.DataFrameSchema(schema_columns, strict=False, checks=checks) + + # Test with valid DataFrame (all location columns present) + df_valid = pd.DataFrame({"lat": [1.0, pd.NA, pd.NA], + "lon": [2.0, pd.NA, pd.NA], + "name": [pd.NA, "a", "b"], + "other": ["value", "a", pd.NA]}) + + # All checks should pass + full_workflow_valid_schema.validate(df_valid) + + def test_full_workflow_invalid_dependencies(self): + """Test complete workflow with invalid column dependencies.""" + # Setup schema with dependencies + names = ["location_group"] + schema_columns = { + "lat": pa.Column(metadata={"location_group": True}), + "lon": pa.Column(metadata={"location_group": True}), + } + + # Create checks + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + # Create schema + full_workflow_invalid_schema = pa.DataFrameSchema(schema_columns, strict=False, checks=checks) + + # Test with invalid DataFrame (only one location column present) + df_invalid = pd.DataFrame({"lat": [1.0], "name": ["Test"]}) + + with pytest.raises(pa.errors.SchemaError): + full_workflow_invalid_schema.validate(df_invalid) + + def test_full_workflow_invalid_entry_dependencies(self): + """Test complete workflow with invalid column dependencies, because of nullable entry.""" + # Setup schema with dependencies + names = ["location_group"] + schema_columns = { + "lat": pa.Column(metadata={"location_group": True}), + "lon": pa.Column(metadata={"location_group": True}), + } + + # Create checks + checks = create_column_dependency_checks_from_metadata(names, schema_columns) + + # Create schema + full_workflow_invalid_schema = pa.DataFrameSchema(schema_columns, strict=False, checks=checks) + + # Test with invalid DataFrame (not assigned entry) + df_invalid_na = pd.DataFrame({"lat": [pd.NA], "name": ["Test"]}) + + with pytest.raises(pa.errors.SchemaError): + full_workflow_invalid_schema.validate(df_invalid_na) + + # Test with invalid DataFrame (None entry) + df_invalid_none = pd.DataFrame({"lat": None, "name": ["Test"]}) + + with pytest.raises(pa.errors.SchemaError): + full_workflow_invalid_schema.validate(df_invalid_none) + + # Test with invalid DataFrame (nan entry) + df_invalid_nan = pd.DataFrame({"lat": float(np.nan), "name": ["Test"]}) + + with pytest.raises(pa.errors.SchemaError): + full_workflow_invalid_schema.validate(df_invalid_nan) + + # Test with invalid DataFrame (multi row null entry) + df_invalid_row = pd.DataFrame( + {"lat": [1.0, np.nan, 1.0, 1.0, 1.0], "name": ["Test", "Test", pd.NA, "Test", "Test"]} + ) + + with pytest.raises(pa.errors.SchemaError): + full_workflow_invalid_schema.validate(df_invalid_row) diff --git a/pandapower/test/network_schema/tools/test_pandera_helper.py b/pandapower/test/network_schema/tools/test_pandera_helper.py new file mode 100644 index 0000000000..6ce9c58b5d --- /dev/null +++ b/pandapower/test/network_schema/tools/test_pandera_helper.py @@ -0,0 +1,458 @@ +import pytest +import pandera as pa +import pandas as pd +import numpy as np +from pandapower.network_schema.tools.helper import get_dtypes + + +class TestGetDtypes: + """Test suite for the get_dtypes function.""" + + def setup_method(self): + """Set up test fixtures before each test method.""" + # Schema with mixed required/optional columns + self.mixed_schema = pa.DataFrameSchema( + { + "name": pa.Column(str, required=True), + "age": pa.Column(int, required=True), + "score": pa.Column(float, required=False), + "active": pa.Column(bool, required=False), + "category": pa.Column(str, required=True), + } + ) + + # Schema with pandas dtypes + self.pandas_dtypes_schema = pa.DataFrameSchema( + { + "string_col": pa.Column(pd.StringDtype(), required=True), + "int_col": pa.Column(pd.Int64Dtype(), required=True), + "float_col": pa.Column(pd.Float64Dtype(), required=False), + "bool_col": pa.Column(pd.BooleanDtype(), required=False), + "categorical_col": pa.Column(pd.CategoricalDtype(["A", "B", "C"]), required=True), + } + ) + + # Schema with pandas datetime dtypes + self.datetime_schema = pa.DataFrameSchema( + { + "datetime_col": pa.Column(pd.DatetimeTZDtype(tz="UTC"), required=True), + "date_col": pa.Column("datetime64[ns]", required=False), + "period_col": pa.Column(pd.PeriodDtype(freq="D"), required=True), + "timedelta_col": pa.Column("timedelta64[ns]", required=False), + } + ) + + # Schema with numpy dtypes + self.numpy_dtypes_schema = pa.DataFrameSchema( + { + "int8_col": pa.Column(np.int8, required=True), + "int16_col": pa.Column(np.int16, required=False), + "int32_col": pa.Column(np.int32, required=True), + "int64_col": pa.Column(np.int64, required=False), + "float32_col": pa.Column(np.float32, required=True), + "float64_col": pa.Column(np.float64, required=False), + "complex128_col": pa.Column(np.complex128, required=True), + } + ) + + # Schema with mixed pandas and numpy dtypes + self.mixed_dtypes_schema = pa.DataFrameSchema( + { + "pandas_string": pa.Column(pd.StringDtype(), required=True), + "numpy_int": pa.Column(np.int64, required=True), + "pandas_float": pa.Column(pd.Float32Dtype(), required=False), + "python_bool": pa.Column(bool, required=False), + "pandas_categorical": pa.Column(pd.CategoricalDtype(), required=True), + } + ) + + # Empty schema + self.empty_schema = pa.DataFrameSchema({}) + + def test_get_dtypes_required_only_default(self): + """Test get_dtypes with default required_only=True.""" + result = get_dtypes(self.mixed_schema) + expected = {"name": str, "age": int, "category": str} + assert result == expected + + def test_get_dtypes_required_only_true(self): + """Test get_dtypes with explicit required_only=True.""" + result = get_dtypes(self.mixed_schema, required_only=True) + expected = {"name": str, "age": int, "category": str} + assert result == expected + + def test_get_dtypes_required_only_false(self): + """Test get_dtypes with required_only=False.""" + result = get_dtypes(self.mixed_schema, required_only=False) + expected = {"name": str, "age": int, "score": float, "active": bool, "category": str} + assert result == expected + + def test_get_dtypes_pandas_dtypes_required_only(self): + """Test get_dtypes with pandas dtypes and required_only=True.""" + result = get_dtypes(self.pandas_dtypes_schema, required_only=True) + + assert len(result) == 3 + assert "string_col" in result + assert "int_col" in result + assert "categorical_col" in result + + assert isinstance(result["string_col"], pd.StringDtype) + assert isinstance(result["int_col"], pd.Int64Dtype) + assert isinstance(result["categorical_col"], pd.CategoricalDtype) + + def test_get_dtypes_pandas_dtypes_all_columns(self): + """Test get_dtypes with pandas dtypes and required_only=False.""" + result = get_dtypes(self.pandas_dtypes_schema, required_only=False) + + assert len(result) == 5 + assert isinstance(result["string_col"], pd.StringDtype) + assert isinstance(result["int_col"], pd.Int64Dtype) + assert isinstance(result["float_col"], pd.Float64Dtype) + assert isinstance(result["bool_col"], pd.BooleanDtype) + assert isinstance(result["categorical_col"], pd.CategoricalDtype) + + def test_get_dtypes_datetime_dtypes_required_only(self): + """Test get_dtypes with datetime-related pandas dtypes.""" + result = get_dtypes(self.datetime_schema, required_only=True) + + assert len(result) == 2 + assert "datetime_col" in result + assert "period_col" in result + + assert isinstance(result["datetime_col"], pd.DatetimeTZDtype) + assert isinstance(result["period_col"], pd.PeriodDtype) + + def test_get_dtypes_datetime_dtypes_all_columns(self): + """Test get_dtypes with datetime dtypes and required_only=False.""" + result = get_dtypes(self.datetime_schema, required_only=False) + + # Check that all columns are present + assert "datetime_col" in result + assert "date_col" in result + assert "period_col" in result + assert "timedelta_col" in result + + assert isinstance(result["datetime_col"], pd.DatetimeTZDtype) + assert isinstance(result["period_col"], pd.PeriodDtype) + + def test_get_dtypes_numpy_dtypes_required_only(self): + """Test get_dtypes with numpy dtypes and required_only=True.""" + result = get_dtypes(self.numpy_dtypes_schema, required_only=True) + expected = { + "int8_col": np.int8, + "int32_col": np.int32, + "float32_col": np.float32, + "complex128_col": np.complex128, + } + assert result == expected + + def test_get_dtypes_numpy_dtypes_all_columns(self): + """Test get_dtypes with numpy dtypes and required_only=False.""" + result = get_dtypes(self.numpy_dtypes_schema, required_only=False) + expected = { + "int8_col": np.int8, + "int16_col": np.int16, + "int32_col": np.int32, + "int64_col": np.int64, + "float32_col": np.float32, + "float64_col": np.float64, + "complex128_col": np.complex128, + } + assert result == expected + + def test_get_dtypes_mixed_dtypes_required_only(self): + """Test get_dtypes with mixed pandas and numpy dtypes.""" + result = get_dtypes(self.mixed_dtypes_schema, required_only=True) + + assert len(result) == 3 + assert "pandas_string" in result + assert "numpy_int" in result + assert "pandas_categorical" in result + + assert isinstance(result["pandas_string"], pd.StringDtype) + assert result["numpy_int"] == np.int64 + assert isinstance(result["pandas_categorical"], pd.CategoricalDtype) + + def test_get_dtypes_mixed_dtypes_all_columns(self): + """Test get_dtypes with mixed dtypes and required_only=False.""" + result = get_dtypes(self.mixed_dtypes_schema, required_only=False) + + assert len(result) == 5 + assert isinstance(result["pandas_string"], pd.StringDtype) + assert result["numpy_int"] == np.int64 + assert isinstance(result["pandas_float"], pd.Float32Dtype) + assert result["python_bool"] == bool + assert isinstance(result["pandas_categorical"], pd.CategoricalDtype) + + def test_get_dtypes_all_required_schema(self): + """Test get_dtypes with schema containing only required columns.""" + all_required_schema = pa.DataFrameSchema( + {"id": pa.Column(int, required=True), "title": pa.Column(str, required=True)} + ) + + result_required_only = get_dtypes(all_required_schema, required_only=True) + result_all = get_dtypes(all_required_schema, required_only=False) + + expected = {"id": int, "title": str} + + assert result_required_only == expected + assert result_all == expected + + def test_get_dtypes_all_optional_schema_required_only(self): + """Test get_dtypes with schema containing only optional columns and required_only=True.""" + all_optional_schema = pa.DataFrameSchema( + {"notes": pa.Column(str, required=False), "rating": pa.Column(float, required=False)} + ) + + result = get_dtypes(all_optional_schema, required_only=True) + assert result == {} + + def test_get_dtypes_all_optional_schema_all_columns(self): + """Test get_dtypes with schema containing only optional columns and required_only=False.""" + all_optional_schema = pa.DataFrameSchema( + {"notes": pa.Column(str, required=False), "rating": pa.Column(float, required=False)} + ) + + result = get_dtypes(all_optional_schema, required_only=False) + expected = {"notes": str, "rating": float} + assert result == expected + + def test_get_dtypes_empty_schema(self): + """Test get_dtypes with empty schema.""" + result_required_only = get_dtypes(self.empty_schema, required_only=True) + result_all = get_dtypes(self.empty_schema, required_only=False) + + assert result_required_only == {} + assert result_all == {} + + def test_get_dtypes_categorical_with_categories(self): + """Test get_dtypes with categorical dtype that has specific categories.""" + schema = pa.DataFrameSchema( + { + "status": pa.Column(pd.CategoricalDtype(["active", "inactive", "pending"]), required=True), + "priority": pa.Column(pd.CategoricalDtype(["low", "medium", "high"], ordered=True), required=False), + } + ) + + result_required = get_dtypes(schema, required_only=True) + result_all = get_dtypes(schema, required_only=False) + + assert len(result_required) == 1 + assert "status" in result_required + assert isinstance(result_required["status"], pd.CategoricalDtype) + + assert len(result_all) == 2 + assert isinstance(result_all["status"], pd.CategoricalDtype) + assert isinstance(result_all["priority"], pd.CategoricalDtype) + + def test_get_dtypes_interval_dtype(self): + """Test get_dtypes with pandas IntervalDtype.""" + schema = pa.DataFrameSchema( + { + "intervals": pa.Column(pd.IntervalDtype(subtype="int64"), required=True), + "float_intervals": pa.Column(pd.IntervalDtype(subtype="float64"), required=False), + } + ) + + result_required = get_dtypes(schema, required_only=True) + result_all = get_dtypes(schema, required_only=False) + + assert len(result_required) == 1 + assert isinstance(result_required["intervals"], pd.IntervalDtype) + + assert len(result_all) == 2 + assert isinstance(result_all["intervals"], pd.IntervalDtype) + assert isinstance(result_all["float_intervals"], pd.IntervalDtype) + + def test_get_dtypes_sparse_dtype(self): + """Test get_dtypes with pandas SparseDtype.""" + schema = pa.DataFrameSchema( + { + "sparse_int": pa.Column(pd.SparseDtype(dtype=np.int64, fill_value=0), required=True), + "sparse_float": pa.Column(pd.SparseDtype(dtype=np.float64), required=False), + } + ) + + result_required = get_dtypes(schema, required_only=True) + result_all = get_dtypes(schema, required_only=False) + + assert len(result_required) == 1 + assert isinstance(result_required["sparse_int"], pd.SparseDtype) + + assert len(result_all) == 2 + assert isinstance(result_all["sparse_int"], pd.SparseDtype) + assert isinstance(result_all["sparse_float"], pd.SparseDtype) + + def test_get_dtypes_return_type(self): + """Test that get_dtypes returns a dictionary.""" + result = get_dtypes(self.mixed_schema) + assert isinstance(result, dict) + + def test_get_dtypes_keys_are_strings(self): + """Test that all keys in the returned dictionary are strings.""" + result = get_dtypes(self.mixed_schema, required_only=False) + for key in result.keys(): + assert isinstance(key, str) + + def test_get_dtypes_comprehensive_mixed_schema(self): + """Comprehensive test with a schema containing various dtype combinations.""" + comprehensive_schema = pa.DataFrameSchema( + { + # Python built-ins + "python_str": pa.Column(str, required=True), + "python_int": pa.Column(int, required=False), + "python_float": pa.Column(float, required=True), + "python_bool": pa.Column(bool, required=False), + # Pandas extension dtypes + "pandas_string": pa.Column(pd.StringDtype(), required=True), + "pandas_int": pa.Column(pd.Int32Dtype(), required=False), + "pandas_float": pa.Column(pd.Float32Dtype(), required=True), + "pandas_bool": pa.Column(pd.BooleanDtype(), required=False), + # Numpy dtypes + "numpy_int": pa.Column(np.int64, required=True), + "numpy_float": pa.Column(np.float32, required=False), + # Specialized pandas dtypes + "categorical": pa.Column(pd.CategoricalDtype(["A", "B"]), required=True), + "datetime_tz": pa.Column(pd.DatetimeTZDtype(tz="UTC"), required=False), + } + ) + + result_required = get_dtypes(comprehensive_schema, required_only=True) + result_all = get_dtypes(comprehensive_schema, required_only=False) + + # Test required columns + assert len(result_required) == 6 + assert result_required["python_str"] == str + assert result_required["python_float"] == float + assert isinstance(result_required["pandas_string"], pd.StringDtype) + assert isinstance(result_required["pandas_float"], pd.Float32Dtype) + assert result_required["numpy_int"] == np.int64 + assert isinstance(result_required["categorical"], pd.CategoricalDtype) + + # Test all columns + assert len(result_all) == 12 + assert result_all["python_str"] == str + assert result_all["python_int"] == int + assert result_all["python_float"] == float + assert result_all["python_bool"] == bool + assert isinstance(result_all["pandas_string"], pd.StringDtype) + assert isinstance(result_all["pandas_int"], pd.Int32Dtype) + assert isinstance(result_all["pandas_float"], pd.Float32Dtype) + assert isinstance(result_all["pandas_bool"], pd.BooleanDtype) + assert result_all["numpy_int"] == np.int64 + assert result_all["numpy_float"] == np.float32 + assert isinstance(result_all["categorical"], pd.CategoricalDtype) + assert isinstance(result_all["datetime_tz"], pd.DatetimeTZDtype) + + +# Parametrized tests with different pandas dtypes + + +@pytest.mark.parametrize( + "dtype_instance,expected_type_class", + [ + (pd.StringDtype(), pd.StringDtype), + (pd.Int64Dtype(), pd.Int64Dtype), + (pd.Float64Dtype(), pd.Float64Dtype), + (pd.BooleanDtype(), pd.BooleanDtype), + (pd.CategoricalDtype(), pd.CategoricalDtype), + (pd.DatetimeTZDtype(tz="UTC"), pd.DatetimeTZDtype), + (pd.PeriodDtype(freq="D"), pd.PeriodDtype), + (pd.IntervalDtype(), pd.IntervalDtype), + (pd.SparseDtype(dtype=np.int64), pd.SparseDtype), + ], +) +def test_get_dtypes_parametrized_pandas_dtypes(dtype_instance, expected_type_class): + """Parametrized tests for various pandas dtypes.""" + schema = pa.DataFrameSchema({"test_col": pa.Column(dtype_instance, required=True)}) + + result = get_dtypes(schema, required_only=True) + assert len(result) == 1 + assert "test_col" in result + assert isinstance(result["test_col"], expected_type_class) + + +@pytest.mark.parametrize( + "numpy_dtype", + [ + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.float16, + np.float32, + np.float64, + np.complex64, + np.complex128, + np.bool_, + ], +) +def test_get_dtypes_parametrized_numpy_dtypes(numpy_dtype): + """Parametrized tests for various numpy dtypes.""" + schema = pa.DataFrameSchema({"test_col": pa.Column(numpy_dtype, required=True)}) + + result = get_dtypes(schema, required_only=True) + assert result == {"test_col": numpy_dtype} + + +@pytest.mark.parametrize( + "required_only,expected_length", + [ + (True, 3), # only required columns + (False, 5), # all columns + ], +) +def test_get_dtypes_parametrized_mixed_schema(required_only, expected_length): + """Parametrized tests for get_dtypes with mixed required/optional columns.""" + schema = pa.DataFrameSchema( + { + "name": pa.Column(str, required=True), + "age": pa.Column(int, required=True), + "score": pa.Column(float, required=False), + "active": pa.Column(bool, required=False), + "category": pa.Column(str, required=True), + } + ) + + result = get_dtypes(schema, required_only=required_only) + assert len(result) == expected_length + + +def test_get_dtypes_invalid_input(): + """Test get_dtypes with invalid input.""" + with pytest.raises(AttributeError): + get_dtypes(None) + + with pytest.raises(AttributeError): + get_dtypes("not_a_schema") + + +def test_get_dtypes_edge_cases(): + """Test edge cases and boundary conditions.""" + # Schema with object dtype + object_schema = pa.DataFrameSchema({"object_col": pa.Column(object, required=True)}) + + result = get_dtypes(object_schema) + assert result == {"object_col": object} + + # Schema with mixed Python and pandas nullable dtypes + mixed_nullable_schema = pa.DataFrameSchema( + { + "regular_int": pa.Column(int, required=True), + "nullable_int": pa.Column(pd.Int64Dtype(), required=True), + "regular_float": pa.Column(float, required=False), + "nullable_float": pa.Column(pd.Float64Dtype(), required=False), + } + ) + + result = get_dtypes(mixed_nullable_schema, required_only=False) + + assert len(result) == 4 + assert result["regular_int"] == int + assert isinstance(result["nullable_int"], pd.Int64Dtype) + assert result["regular_float"] == float + assert isinstance(result["nullable_float"], pd.Float64Dtype) diff --git a/pandapower/test/opf/test_basic.py b/pandapower/test/opf/test_basic.py index 6d06503621..2aef718b90 100644 --- a/pandapower/test/opf/test_basic.py +++ b/pandapower/test/opf/test_basic.py @@ -4,21 +4,37 @@ # and Energy System Technology (IEE), Kassel. All rights reserved. +import logging +from copy import deepcopy + import numpy as np import pytest -from copy import deepcopy +import pandapower.pypower.pipsopf_solver as pipsopf_solver_module from pandapower.auxiliary import OPFNotConverged from pandapower.convert_format import convert_format -from pandapower.create import create_empty_network, create_bus, create_gen, create_ext_grid, create_load, \ - create_poly_cost, create_line_from_parameters, create_transformer3w_from_parameters, create_line, create_sgen, \ - create_transformer_from_parameters, create_transformer3w, create_pwl_cost, create_storage +from pandapower.create import ( + create_bus, + create_empty_network, + create_ext_grid, + create_gen, + create_line, + create_line_from_parameters, + create_load, + create_poly_cost, + create_pwl_cost, + create_sgen, + create_storage, + create_transformer3w, + create_transformer3w_from_parameters, + create_transformer_from_parameters, +) +from pandapower.create._utils import add_column_to_df from pandapower.networks import simple_four_bus_system -from pandapower.run import runopp, rundcopp, runpp +from pandapower.pypower.opf_model import opf_model +from pandapower.run import rundcopp, runopp, runpp from pandapower.test.helper_functions import add_grid_connection -import logging - logger = logging.getLogger(__name__) @@ -42,6 +58,34 @@ def simplest_grid(): return net +def test_runopp_init_results_preserves_model_v0(monkeypatch): + net = simplest_grid() + runpp(net, calculate_voltage_angles=False) + + captured = {} + original_getv = opf_model.getv + + def recording_getv(self): + v0, vl, vu = original_getv(self) + captured["model_v0"] = v0.copy() + return v0, vl, vu + + class StopAfterCapturingX0(RuntimeError): + pass + + def recording_pips(f_fcn, x0, A, l, u, xmin, xmax, gh_fcn, hess_fcn, opt): + captured["solver_x0"] = x0.copy() + raise StopAfterCapturingX0 + + monkeypatch.setattr(opf_model, "getv", recording_getv) + monkeypatch.setattr(pipsopf_solver_module, "pips", recording_pips) + + with pytest.raises(StopAfterCapturingX0): + runopp(net, init="results", calculate_voltage_angles=False) + + assert "model_v0" in captured + assert "solver_x0" in captured + assert np.allclose(captured["solver_x0"], captured["model_v0"]) @pytest.fixture(scope='session') def net_3w_trafo_opf(): @@ -884,12 +928,13 @@ def test_only_gen_slack_vm_setpoint(four_bus_net): net.bus.loc[:, "min_vm_pu"] = 0.9 net.bus.loc[:, "max_vm_pu"] = 1.1 # create two additional slacks with different voltage setpoints - create_gen(net, 0, p_mw=0., vm_pu=1., max_p_mw=1., min_p_mw=-1., min_q_mvar=-1, - max_q_mvar=1., slack=True) - create_gen(net, 1, p_mw=0.02, vm_pu=1.01, max_p_mw=1., min_p_mw=-1., min_q_mvar=-1, - max_q_mvar=1., controllable=False) # controllable == False -> vm_pu enforced - create_gen(net, 3, p_mw=0.01, vm_pu=1.02, max_p_mw=1., min_p_mw=-1., - min_q_mvar=-1, max_q_mvar=1.) # controllable == True -> vm_pu between + create_gen(net, 0, p_mw=0., vm_pu=1., max_p_mw=1., min_p_mw=-1., min_q_mvar=-1, max_q_mvar=1., slack=True, + controllable=True) + create_gen(net, 1, p_mw=0.02, vm_pu=1.01, max_p_mw=1., min_p_mw=-1., min_q_mvar=-1, max_q_mvar=1., + controllable=False) # controllable == False -> vm_pu enforced + create_gen(net, 3, p_mw=0.01, vm_pu=1.02, max_p_mw=1., min_p_mw=-1., min_q_mvar=-1, max_q_mvar=1., + controllable=True) # controllable == True -> vm_pu between + # bus voltages runpp(net) # assert if voltage limits are correct in result in pf an opf @@ -936,6 +981,10 @@ def test_gen_p_vm_limits(four_bus_net): # controllable == False -> limits are ignored and p_mw / vm_pu values are enforced create_gen(net, bus, p_mw=0.02, vm_pu=1.01, controllable=True, min_vm_pu=min_vm_pu, max_vm_pu=max_vm_pu, min_p_mw=min_p_mw, max_p_mw=max_p_mw) + + add_column_to_df(net, "gen", "min_q_mvar") + add_column_to_df(net, "gen", "max_q_mvar") + runopp(net, calculate_voltage_angles=False) assert not np.allclose(net.res_bus.at[bus, "vm_pu"], 1.01) assert not np.allclose(net.res_bus.at[bus, "p_mw"], 0.02) @@ -955,6 +1004,10 @@ def test_gen_violated_p_vm_limits(four_bus_net): # controllable == False -> limits are ignored and p_mw / vm_pu values are enforced g = create_gen(net, bus, p_mw=0.02, vm_pu=1.01, controllable=True, min_vm_pu=.9, max_vm_pu=1.1, min_p_mw=min_p_mw, max_p_mw=max_p_mw) + + add_column_to_df(net, "gen", "min_q_mvar") + add_column_to_df(net, "gen", "max_q_mvar") + runopp(net, calculate_voltage_angles=False) assert not np.allclose(net.res_bus.at[bus, "vm_pu"], 1.01) assert not np.allclose(net.res_bus.at[bus, "p_mw"], 0.02) diff --git a/pandapower/test/opf/test_costs_pwl.py b/pandapower/test/opf/test_costs_pwl.py index 8b8015dc6f..26c02f4931 100644 --- a/pandapower/test/opf/test_costs_pwl.py +++ b/pandapower/test/opf/test_costs_pwl.py @@ -9,6 +9,7 @@ from pandapower.create import create_empty_network, create_bus, create_gen, create_ext_grid, create_load, \ create_line_from_parameters, create_pwl_cost, create_sgen +from pandapower.create._utils import add_column_to_df from pandapower.run import runopp import logging @@ -55,6 +56,8 @@ def test_cost_piecewise_linear_eg(): create_bus(net, max_vm_pu=vm_max, min_vm_pu=vm_min, vn_kv=10) create_ext_grid(net, 0, min_p_mw=0, max_p_mw=0.050) create_gen(net, 1, p_mw=0.01, min_p_mw=0, max_p_mw=0.050, controllable=True) + add_column_to_df(net, "gen", "min_q_mvar") + add_column_to_df(net, "gen", "max_q_mvar") # create_ext_grid(net, 0) create_load(net, 1, p_mw=0.02, controllable=False) create_line_from_parameters(net, 0, 1, 50, name="line2", r_ohm_per_km=0.876, diff --git a/pandapower/test/opf/test_curtailment.py b/pandapower/test/opf/test_curtailment.py index cc11e84953..39a96ab0e3 100644 --- a/pandapower/test/opf/test_curtailment.py +++ b/pandapower/test/opf/test_curtailment.py @@ -9,6 +9,7 @@ from pandapower.create import create_empty_network, create_bus, create_transformer, create_line, create_load, \ create_ext_grid, create_gen, create_poly_cost +from pandapower.create._utils import add_column_to_df from pandapower.run import runopp import logging @@ -43,6 +44,9 @@ def test_minimize_active_power_curtailment(): create_gen(net, bus3, p_mw=80., max_p_mw=80., min_p_mw=0., vm_pu=1.01, controllable=True) create_gen(net, bus4, p_mw=0.1, max_p_mw=100., min_p_mw=0., vm_pu=1.01, controllable=True) + add_column_to_df(net, "gen", "min_q_mvar") + add_column_to_df(net, "gen", "max_q_mvar") + net.trafo["max_loading_percent"] = 50. net.line["max_loading_percent"] = 50. diff --git a/pandapower/test/opf/test_pp_vs_pm.py b/pandapower/test/opf/test_pp_vs_pm.py index 8d0841463e..e2559e325f 100644 --- a/pandapower/test/opf/test_pp_vs_pm.py +++ b/pandapower/test/opf/test_pp_vs_pm.py @@ -96,10 +96,10 @@ def test_case5_pm_pd2ppc(): assert net.bus.max_vm_pu[net.ext_grid.bus].values[0] == vmax assert net.ext_grid["in_service"].values.dtype == bool - assert net.ext_grid["bus"].values.dtype == "uint32" + assert net.ext_grid["bus"].values.dtype == "int64" create_ext_grid(net, bus=4, vm_pu=net.res_bus.vm_pu.loc[4], controllable=False) - assert net.ext_grid["bus"].values.dtype == "uint32" + assert net.ext_grid["bus"].values.dtype == "int64" assert net.ext_grid["in_service"].values.dtype == bool ppc = _pd2ppc(net) diff --git a/pandapower/test/plotting/test_geo.py b/pandapower/test/plotting/test_geo.py index 41b794f687..5afa3d6b95 100644 --- a/pandapower/test/plotting/test_geo.py +++ b/pandapower/test/plotting/test_geo.py @@ -227,90 +227,83 @@ def test_dump_to_geojson(): # test exporting buses result = dump_to_geojson(_net, buses=True) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, ' - '"id": "bus-1", "properties": {"in_service": true, "name": "bus2", ' - '"pp_index": 1, "pp_type": "bus", "type": "b", "vn_kv": 0.4, ' - '"zone": null}, "type": "Feature"}, {"geometry": {"coordinates": [1.0, ' - '3.0], "type": "Point"}, "id": "bus-7", "properties": {"in_service": ' - 'true, "name": "bus3", "pp_index": 7, "pp_type": "bus", "type": "b", ' - '"vn_kv": 0.4, "zone": null}, "type": "Feature"}], ' - '"type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, "id": "bus-1", ' + '"properties": {"in_service": true, "name": "bus2", "pp_index": 1, "pp_type": "bus", "type": "b", ' + '"vn_kv": 0.4}, "type": "Feature"}, {"geometry": {"coordinates": [1.0, 3.0], "type": "Point"}, "id": "bus-7", ' + '"properties": {"in_service": true, "name": "bus3", "pp_index": 7, "pp_type": "bus", "type": "b", ' + '"vn_kv": 0.4}, "type": "Feature"}], "type": "FeatureCollection"}' + ) # test exporting lines result = dump_to_geojson(_net, lines=True) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], ' - '"type": "LineString"}, "id": "line-0", "properties": {"c_nf_per_km": ' - '720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "ices": 0.389985, ' - '"in_service": true, "length_km": 1.0, "max_i_ka": 0.328, ' - '"name": "line1", "parallel": 1, "pp_index": 0, "pp_type": "line", ' - '"r_ohm_per_km": 0.2067, "std_type": null, "to_bus": 7, "type": null, ' - '"x_ohm_per_km": 0.1897522}, "type": "Feature"}], ' - '"type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], "type": "LineString"}, "id": "line-0", ' + '"properties": {"c_nf_per_km": 720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "ices": 0.389985, ' + '"in_service": true, "length_km": 1.0, "max_i_ka": 0.328, "name": "line1", "parallel": 1, "pp_index": 0, ' + '"pp_type": "line", "r_ohm_per_km": 0.2067, "to_bus": 7, "x_ohm_per_km": 0.1897522}, "type": "Feature"}], ' + '"type": "FeatureCollection"}' + ) # test exporting both result = dump_to_geojson(_net, buses=True, lines=True) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, ' - '"id": "bus-1", "properties": {"in_service": true, "name": "bus2", ' - '"pp_index": 1, "pp_type": "bus", "type": "b", "vn_kv": 0.4, ' - '"zone": null}, "type": "Feature"}, {"geometry": {"coordinates": [1.0, ' - '3.0], "type": "Point"}, "id": "bus-7", "properties": {"in_service": ' - 'true, "name": "bus3", "pp_index": 7, "pp_type": "bus", "type": "b", ' - '"vn_kv": 0.4, "zone": null}, "type": "Feature"}, {"geometry": {' - '"coordinates": [[1.0, 2.0], [3.0, 4.0]], "type": "LineString"}, ' - '"id": "line-0", "properties": {"c_nf_per_km": 720.0, "df": 1.0, ' - '"from_bus": 1, "g_us_per_km": 0.0, "ices": 0.389985, "in_service": ' - 'true, "length_km": 1.0, "max_i_ka": 0.328, "name": "line1", "parallel": ' - '1, "pp_index": 0, "pp_type": "line", "r_ohm_per_km": 0.2067, ' - '"std_type": null, "to_bus": 7, "type": null, "x_ohm_per_km": ' - '0.1897522}, "type": "Feature"}], "type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, "id": "bus-1", "properties": {' + '"in_service": true, "name": "bus2", "pp_index": 1, "pp_type": "bus", "type": "b", "vn_kv": 0.4}, ' + '"type": "Feature"}, {"geometry": {"coordinates": [1.0, 3.0], "type": "Point"}, "id": "bus-7", ' + '"properties": {"in_service": true, "name": "bus3", "pp_index": 7, "pp_type": "bus", "type": "b", ' + '"vn_kv": 0.4}, "type": "Feature"}, {"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], ' + '"type": "LineString"}, "id": "line-0", "properties": {"c_nf_per_km": 720.0, "df": 1.0, "from_bus": 1, ' + '"g_us_per_km": 0.0, "ices": 0.389985, "in_service": true, "length_km": 1.0, "max_i_ka": 0.328, ' + '"name": "line1", "parallel": 1, "pp_index": 0, "pp_type": "line", "r_ohm_per_km": 0.2067, "to_bus": 7, ' + '"x_ohm_per_km": 0.1897522}, "type": "Feature"}], "type": "FeatureCollection"}' + ) # test exporting specific buses result = dump_to_geojson(_net, buses=[1]) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, ' - '"id": "bus-1", "properties": {"in_service": true, "name": "bus2", ' - '"pp_index": 1, "pp_type": "bus", "type": "b", "vn_kv": 0.4, ' - '"zone": null}, "type": "Feature"}], "type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, "id": "bus-1", "properties": {' + '"in_service": true, "name": "bus2", "pp_index": 1, "pp_type": "bus", "type": "b", "vn_kv": 0.4}, ' + '"type": "Feature"}], "type": "FeatureCollection"}' + ) # test exporting specific lines result = dump_to_geojson(_net, lines=[0]) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], ' - '"type": "LineString"}, "id": "line-0", "properties": {"c_nf_per_km": ' - '720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "ices": 0.389985, ' - '"in_service": true, "length_km": 1.0, "max_i_ka": 0.328, ' - '"name": "line1", "parallel": 1, "pp_index": 0, "pp_type": "line", ' - '"r_ohm_per_km": 0.2067, "std_type": null, "to_bus": 7, "type": null, ' - '"x_ohm_per_km": 0.1897522}, "type": "Feature"}], ' - '"type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], "type": "LineString"}, "id": "line-0", ' + '"properties": {"c_nf_per_km": 720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "ices": 0.389985, ' + '"in_service": true, "length_km": 1.0, "max_i_ka": 0.328, "name": "line1", "parallel": 1, "pp_index": 0, ' + '"pp_type": "line", "r_ohm_per_km": 0.2067, "to_bus": 7, "x_ohm_per_km": 0.1897522}, "type": "Feature"}], ' + '"type": "FeatureCollection"}' + ) # test exporting props from bus and res_bus _net.res_bus.loc[1, ["vm_pu", "va_degree", "p_mw", "q_mvar"]] = [1.0, 1.0, 1.0, 1.0] result = dump_to_geojson(_net, buses=[1]) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, ' - '"id": "bus-1", "properties": {"in_service": true, "name": "bus2", ' - '"p_mw": 1.0, "pp_index": 1, "pp_type": "bus", "q_mvar": 1.0, ' - '"type": "b", "va_degree": 1.0, "vm_pu": 1.0, "vn_kv": 0.4, ' - '"zone": null}, "type": "Feature"}], "type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [1.0, 2.0], "type": "Point"}, "id": "bus-1", "properties": {' + '"in_service": true, "name": "bus2", "p_mw": 1.0, "pp_index": 1, "pp_type": "bus", "q_mvar": 1.0, "type": "b", ' + '"va_degree": 1.0, "vm_pu": 1.0, "vn_kv": 0.4}, "type": "Feature"}], "type": "FeatureCollection"}' + ) # test exporting props from line and res_line _net.res_line.loc[0, _net.res_line.columns] = [7.0] * len(_net.res_line.columns) result = dump_to_geojson(_net, lines=[0]) assert isinstance(result, FeatureCollection) - assert dumps(result, sort_keys=True) == ('{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], ' - '"type": "LineString"}, "id": "line-0", "properties": {"c_nf_per_km": ' - '720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "i_from_ka": 7.0, ' - '"i_ka": 7.0, "i_to_ka": 7.0, "ices": 0.389985, "in_service": true, ' - '"length_km": 1.0, "loading_percent": 7.0, "max_i_ka": 0.328, ' - '"name": "line1", "p_from_mw": 7.0, "p_to_mw": 7.0, "parallel": 1, ' - '"pl_mw": 7.0, "pp_index": 0, "pp_type": "line", "q_from_mvar": 7.0, ' - '"q_to_mvar": 7.0, "ql_mvar": 7.0, "r_ohm_per_km": 0.2067, "std_type": ' - 'null, "to_bus": 7, "type": null, "va_from_degree": 7.0, "va_to_degree": ' - '7.0, "vm_from_pu": 7.0, "vm_to_pu": 7.0, "x_ohm_per_km": 0.1897522}, ' - '"type": "Feature"}], "type": "FeatureCollection"}') + assert dumps(result, sort_keys=True) == ( + '{"features": [{"geometry": {"coordinates": [[1.0, 2.0], [3.0, 4.0]], "type": "LineString"}, "id": "line-0", ' + '"properties": {"c_nf_per_km": 720.0, "df": 1.0, "from_bus": 1, "g_us_per_km": 0.0, "i_from_ka": 7.0, ' + '"i_ka": 7.0, "i_to_ka": 7.0, "ices": 0.389985, "in_service": true, "length_km": 1.0, "loading_percent": 7.0, ' + '"max_i_ka": 0.328, "name": "line1", "p_from_mw": 7.0, "p_to_mw": 7.0, "parallel": 1, "pl_mw": 7.0, ' + '"pp_index": 0, "pp_type": "line", "q_from_mvar": 7.0, "q_to_mvar": 7.0, "ql_mvar": 7.0, ' + '"r_ohm_per_km": 0.2067, "to_bus": 7, "va_from_degree": 7.0, "va_to_degree": 7.0, "vm_from_pu": 7.0, ' + '"vm_to_pu": 7.0, "x_ohm_per_km": 0.1897522}, "type": "Feature"}], "type": "FeatureCollection"}' + ) def test_convert_geodata_to_geojson(): diff --git a/pandapower/test/plotting/test_plotting_toolbox.py b/pandapower/test/plotting/test_plotting_toolbox.py index e87c229656..091c8ecfa3 100644 --- a/pandapower/test/plotting/test_plotting_toolbox.py +++ b/pandapower/test/plotting/test_plotting_toolbox.py @@ -87,5 +87,15 @@ def test_set_line_geodata_from_bus_geodata(): set_line_geodata_from_bus_geodata(net) +def test_simple_hl_plot(): + # test that plotting works with case9 file + net = case9() + load_buses = net.load.bus.values + load_lines = net.line.loc[net.line.from_bus.isin(load_buses) | net.line.to_bus.isin(load_buses)].index + ax = simple_plot(net, highlight_lines=load_lines, highlight_buses=load_buses) + + assert ax is not None + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/pandapower/test/toolbox/test_comparison.py b/pandapower/test/toolbox/test_comparison.py index f3f8aec49b..77a48c6e8e 100644 --- a/pandapower/test/toolbox/test_comparison.py +++ b/pandapower/test/toolbox/test_comparison.py @@ -53,7 +53,10 @@ def test_nets_equal(): assert nets_equal(net, original, atol=0.1) # check controllers - original.trafo.tap_side = original.trafo.tap_side.fillna("hv") + if "tap_side" in original.trafo: + original.trafo.tap_side = original.trafo.tap_side.fillna("hv") + else: + original.trafo["tap_side"] = pd.Series(["hv"]*len(original.trafo), dtype=pd.StringDtype()) net1 = copy.deepcopy(original) net2 = copy.deepcopy(original) ContinuousTapControl(net1, 0, 1.0) diff --git a/pandapower/test/toolbox/test_grid_modification.py b/pandapower/test/toolbox/test_grid_modification.py index a299da2a54..8097c1affb 100644 --- a/pandapower/test/toolbox/test_grid_modification.py +++ b/pandapower/test/toolbox/test_grid_modification.py @@ -787,9 +787,8 @@ def check_elm_shape(net, elm_shape: dict): for to_bus in [1, 2]: create_line(net, 0, to_bus, 0.6, 'NA2XS2Y 1x95 RM/25 12/20 kV') names = ["load 1", "load 2"] - types = ["house", "commercial"] create_loads(net, [1, 2], 0.8, 0.1, sn_mva=1, min_p_mw=0.5, max_p_mw=1.0, controllable=True, - name=names, scaling=[0.8, 1], type=types) + name=names, scaling=[0.8, 1]) create_poly_cost(net, 0, "load", 7) create_poly_cost(net, 1, "load", 3) runpp(net) @@ -797,12 +796,11 @@ def check_elm_shape(net, elm_shape: dict): net_orig = copy.deepcopy(net) # --- test unset old_indices, cols_to_keep and add_cols_to_keep - replace_pq_elmtype(net, "load", "sgen", new_indices=[2, 7], cols_to_keep=["type"], - add_cols_to_keep=["scaling"]) # cols_to_keep is not - # default but ["type"] -> min/max p_mw get lost + replace_pq_elmtype( + net, "load", "sgen", new_indices=[2, 7], cols_to_keep=[], add_cols_to_keep=["scaling"] + ) check_elm_shape(net, {"load": 0, "sgen": 2}) assert list(net.sgen.index) == [2, 7] - assert list(net.sgen.type.values) == types assert list(net.sgen.name.values) == names assert net.sgen.controllable.astype(bool).all() assert "min_p_mw" not in net.sgen.columns @@ -811,7 +809,7 @@ def check_elm_shape(net, elm_shape: dict): # --- test set old_indices and add_cols_to_keep for different element types net = copy.deepcopy(net_orig) - add_cols_to_keep = ["scaling", "type", "sn_mva"] + add_cols_to_keep = ["scaling", "sn_mva"] replace_pq_elmtype(net, "load", "sgen", old_indices=1, add_cols_to_keep=add_cols_to_keep) check_elm_shape(net, {"load": 1, "sgen": 1}) runpp(net) diff --git a/pandapower/test/toolbox/test_result_info.py b/pandapower/test/toolbox/test_result_info.py index 56ee2b537d..63b28ad8c7 100644 --- a/pandapower/test/toolbox/test_result_info.py +++ b/pandapower/test/toolbox/test_result_info.py @@ -11,6 +11,7 @@ create_dcline, create_line, create_transformer, create_bus, create_poly_cost, create_pwl_cost ) +from pandapower.create._utils import add_column_to_df from pandapower.networks import create_cigre_network_lv, case9 from pandapower.run import runpp from pandapower.toolbox import ( @@ -20,8 +21,7 @@ def test_opf_task(): net = create_empty_network() - create_buses(net, 6, [10, 10, 10, 0.4, 7, 7], - min_vm_pu=[0.9, 0.9, 0.88, 0.9, np.nan, np.nan]) + create_buses(net, 6, [10, 10, 10, 0.4, 7, 7], min_vm_pu=[0.9, 0.9, 0.88, 0.9, np.nan, np.nan]) idx_ext_grid = 1 create_ext_grid(net, 0, max_q_mvar=80, min_p_mw=0, index=idx_ext_grid) create_gen(net, 1, 10, min_q_mvar=-50, max_q_mvar=-10, min_p_mw=0, max_p_mw=60) @@ -31,9 +31,14 @@ def test_opf_task(): create_sgen(net, 1, 8, min_q_mvar=-50, max_q_mvar=-10, controllable=False) create_sgen(net, 2, 8) create_storage(net, 3, 2, 100, min_q_mvar=-10, max_q_mvar=-50, min_p_mw=0, max_p_mw=60, - controllable=True) + controllable=True) create_dcline(net, 4, 5, 0.3, 1e-4, 1e-2, 1.01, 1.02, min_q_from_mvar=-10, - min_q_to_mvar=-10) + min_q_to_mvar=-10) + + add_column_to_df(net, "dcline", "max_p_mw") + add_column_to_df(net, "dcline", "max_q_from_mvar") + add_column_to_df(net, "dcline", "max_q_to_mvar") + create_line(net, 3, 4, 5, "122-AL1/20-ST1A 10.0", max_loading_percent=50) create_transformer(net, 2, 3, "0.25 MVA 10/0.4 kV") diff --git a/pandapower/toolbox/comparison.py b/pandapower/toolbox/comparison.py index ade710fe05..469139f562 100644 --- a/pandapower/toolbox/comparison.py +++ b/pandapower/toolbox/comparison.py @@ -124,6 +124,7 @@ def nets_equal(net1, net2, check_only_results=False, check_without_results=False check_without_results (bool, False): if True, result tables (starting with ``res_``) are ignored for comparison exclude_elms (list, None): list of element tables which should be ignored in the comparison name_selection (list, None): list of element tables which should be compared + assume_geojson_strings (bool, True): Keyword Arguments: any: are passed to :func:`dataframes_equal` diff --git a/pandapower/toolbox/data_modification.py b/pandapower/toolbox/data_modification.py index 8d487231d7..b440811607 100644 --- a/pandapower/toolbox/data_modification.py +++ b/pandapower/toolbox/data_modification.py @@ -13,6 +13,7 @@ from pandapower.create.network_create import create_empty_network from pandapower.toolbox.comparison import compare_arrays from pandapower.toolbox.element_selection import element_bus_tuples, pp_elements +from pandapower.network_structure import get_structure_dict import logging @@ -320,8 +321,16 @@ def reindex_elements(net, element_type, new_indices=None, old_indices=None, look if element_type == "trafo_characteristic_table": net["trafo_characteristic_table"]["id_characteristic"] = ( net["trafo_characteristic_table"]["id_characteristic"].map(lookup)) + if "id_characteristic_table" not in net["trafo"]: + net["trafo"]["id_characteristic_table"] = ( + pd.Series(data=[pd.NA] * net["trafo"].shape[0], + dtype=get_structure_dict(required_only=False)['trafo']['id_characteristic_table'])) net["trafo"]["id_characteristic_table"] = ( net["trafo"]["id_characteristic_table"].map(lookup)) + if "id_characteristic_table" not in net["trafo3w"]: + net["trafo3w"]["id_characteristic_table"] = ( + pd.Series(data=[pd.NA] * net["trafo3w"].shape[0], + dtype=get_structure_dict(required_only=False)['trafo3w']['id_characteristic_table'])) net["trafo3w"]["id_characteristic_table"] = ( net["trafo3w"]["id_characteristic_table"].map(lookup)) diff --git a/pandapower/toolbox/grid_modification.py b/pandapower/toolbox/grid_modification.py index 371bf3fdce..db1655268a 100644 --- a/pandapower/toolbox/grid_modification.py +++ b/pandapower/toolbox/grid_modification.py @@ -9,9 +9,11 @@ import numpy as np import pandas as pd + from pandapower.auxiliary import pandapowerNet, _preserve_dtypes, ensure_iterability, \ log_to_level, plural_s from pandapower.std_types import change_std_type +from pandapower.create._utils import add_column_to_df from pandapower.create import ( create_switch, create_line_from_parameters, create_impedance, create_empty_network, create_gen, create_ext_grid, create_load, create_shunt, create_bus, create_sgen, create_storage, create_ward @@ -1275,7 +1277,7 @@ def replace_line_by_impedance(net, index=None, sn_mva=None, only_valid_replace=T xft0_pu=line_.x0_ohm_per_km * l / p / Zni if "x0_ohm_per_km" in cols else None, gf0_pu=line_.g0_us_per_km * 1e-6 * Zni * l * p if "g0_us_per_km" in cols else None, bf0_pu=2 * net.f_hz * np.pi * line_.c0_nf_per_km * 1e-9 * Zni * l * p if "c0_nf_per_km" in cols else None, - name=line_.name, + name=line_["name"], # TODO: should this be line_.name (line index) or line_["name"] in_service=line_.in_service)) i += 1 _replace_group_member_element_type(net, index, "line", new_index, "impedance", @@ -1346,10 +1348,14 @@ def replace_ext_grid_by_gen(net, ext_grids=None, gen_indices=None, slack=False, # --- create gens new_idx = [] - for ext_grid, index in zip(net.ext_grid.loc[ext_grids].itertuples(), gen_indices): + for ext_grid, index in zip(net.ext_grid.loc[ext_grids].itertuples(name="ExtGrid"), gen_indices): p_mw = 0 if ext_grid.Index not in net.res_ext_grid.index else net.res_ext_grid.at[ ext_grid.Index, "p_mw"] - idx = create_gen(net, ext_grid.bus, vm_pu=ext_grid.vm_pu, p_mw=p_mw, name=ext_grid.name, + if hasattr(ext_grid, "name") and pd.notna(ext_grid.name): + name = ext_grid.name + else: + name = "" + idx = create_gen(net, ext_grid.bus, vm_pu=ext_grid.vm_pu, p_mw=p_mw, name=name, in_service=ext_grid.in_service, controllable=True, index=index) new_idx.append(idx) net.gen.loc[new_idx, "slack"] = slack @@ -1707,7 +1713,7 @@ def replace_pq_elmtype(net, old_element_type, new_element_type, old_indices=None # add missing columns to net[new_element_type] which should be kept missing_cols_to_keep = existing_cols_to_keep.difference(net[new_element_type].columns) for col in missing_cols_to_keep: - net[new_element_type][col] = np.nan + add_column_to_df(net, new_element_type, col) # --- create new_element_type already_considered_cols = set() diff --git a/pandapower/topology/create_graph.py b/pandapower/topology/create_graph.py index 78672072b3..3f435d70ae 100644 --- a/pandapower/topology/create_graph.py +++ b/pandapower/topology/create_graph.py @@ -37,7 +37,6 @@ logger = logging.getLogger(__name__) -# TODO: undocumented Parameters def create_nxgraph( net, respect_switches=True, include_lines=True, include_impedances=True, include_dclines=True, include_trafos=True, include_trafo3ws=True, include_tcsc=True, include_vsc=True, include_line_dc=True, nogobuses=None, notravbuses=None, @@ -60,13 +59,13 @@ def create_nxgraph( include_lines (bool or index, True): determines, whether or which lines get converted to edges include_impedances (bool or index, True): determines, whether or which per unit impedances (net.impedance) are converted to edges + include_dclines (bool or index, True): determines, whether or which dclines get converted to edges + include_trafos (bool or index, True): determines, whether or which trafos get converted to edges + include_trafo3ws (bool or index, True): determines, whether or which trafo3ws get converted to edges include_tcsc (bool or index, True): determines, whether or which TCSC elements (net.tcsc) are converted to edges include_vsc (bool or index, True): determines, whether or which VSC elements (net.vsc) are converted to edges include_line_dc (bool or index, True): determines, whether or which DC line elements (net.line_dc) are converted to edges - include_dclines (bool or index, True): determines, whether or which dclines get converted to edges - include_trafos (bool or index, True): determines, whether or which trafos get converted to edges - include_trafo3ws (bool or index, True): determines, whether or which trafo3ws get converted to edges nogobuses (integer/list, None): nogobuses are not being considered in the graph notravbuses (integer/list, None): lines connected to these buses are not being considered in the graph multi (bool, True): @@ -80,7 +79,11 @@ def create_nxgraph( branch_impedance_unit (str, "ohm"): defines the unit of the branch impedance for calc_branch_impedances=True. If it is set to "ohm", the parameters 'r_ohm', 'x_ohm' and 'z_ohm' are added to each branch. If it is set to "pu", the parameters are 'r_pu', 'x_pu' and 'z_pu'. + library: include_out_of_service (bool, False): defines if out of service buses are included in the nx graph + include_switches: + trafo_length_km: + switch_length_km: Returns: Returns the required NetworkX graph diff --git a/pyproject.toml b/pyproject.toml index 7e7703813a..0fa16525d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "deepdiff~=8.6", "geojson~=3.2", "typing_extensions~=4.9", - "pandera~=0.26.1" + "pandera>=0.26.1,<=0.29.0" ] keywords = [ "power system", "network", "analysis", "optimization", "automation", "grid", "electricity", "energy", "engineering", "simulation", "pandapower" diff --git a/tutorials/ieee_european_lv_asymmetric.ipynb b/tutorials/ieee_european_lv_asymmetric.ipynb index 7d7a879a5f..5172ef1eac 100644 --- a/tutorials/ieee_european_lv_asymmetric.ipynb +++ b/tutorials/ieee_european_lv_asymmetric.ipynb @@ -84,28 +84,29 @@ " colors = [\"b\", \"g\", \"r\", \"c\", \"y\"]\n", "%matplotlib inline\n", "\n", - "sizes = plot.get_collection_sizes(net)\n", + "# Adjust bus and trafo sizes for this specific map\n", + "sizes = plot.get_collection_sizes(net, bus_size=0.45, trafo_size=0.8)\n", "\n", "# Plot all the buses\n", "bc = plot.create_bus_collection(net, net.bus.index, size=sizes['bus'], color=colors[0], zorder=10)\n", "\n", - "#Plot Transformers\n", + "# Plot transformers\n", "tlc, tpc = plot.create_trafo_collection(net, net.trafo.index, color=\"g\", size=sizes['trafo'])\n", "\n", "# Plot all the lines\n", - "lcd = plot.create_line_collection(net, net.line.index, color=\"grey\", linewidths=0.1, use_bus_geodata=True)\n", + "lcd = plot.create_line_collection(net, net.line.index, color=\"grey\", linewidths=3, use_bus_geodata=True)\n", "\n", "# Plot the external grid\n", "sc = plot.create_ext_grid_collection(net, ext_grid_buses=net.ext_grid.bus.values, size=sizes['ext_grid'], color=\"c\", zorder=11)\n", "\n", - "#Plot all the loads\n", - "ldA = plot.create_bus_collection(net, net.asymmetric_load.bus.values[np.nonzero(net.asymmetric_load.p_a_mw > 0)], patch_type=\"poly3\", size=sizes['bus'], color=\"r\", zorder=11)\n", + "# Plot all the loads\n", + "ldA = plot.create_bus_collection(net, net.asymmetric_load.bus.values[np.nonzero(net.asymmetric_load.p_a_mw > 0)], patch_type=\"poly3\", size=sizes['bus']*1.2, color=\"r\", zorder=11)\n", "ldB = plot.create_bus_collection(net, net.asymmetric_load.bus.values[np.nonzero(net.asymmetric_load.p_b_mw > 0)], patch_type=\"rect\", size=sizes['bus'], color=\"y\", zorder=11)\n", "ldC = plot.create_bus_collection(net, net.asymmetric_load.bus.values[np.nonzero(net.asymmetric_load.p_c_mw > 0)], patch_type=\"circle\", size=sizes['bus'], color=\"b\", zorder=11)\n", "\n", - "# Plot the max. loaded line and max. unbalanced node\n", - "max_load = plot.create_line_collection(net, np.array([net.res_line_3ph.loading_percent.idxmax()]), color=\"black\", linewidths=15, use_bus_geodata=True)\n", - "max_unbal = plot.create_bus_collection(net, np.array([net.res_bus_3ph.unbalance_percent.idxmax()]), patch_type=\"rect\", size=sizes['bus'], color=\"black\", zorder=11)\n", + "# Plot the max. loaded line and max. unbalanced node in black and slighly bigger\n", + "max_load = plot.create_line_collection(net, np.array([net.res_line_3ph.loading_percent.idxmax()]), color=\"black\", linewidths=12, use_bus_geodata=True)\n", + "max_unbal = plot.create_bus_collection(net, np.array([net.res_bus_3ph.unbalance_percent.idxmax()]), patch_type=\"rect\", size=sizes['bus']*1.5, color=\"black\", zorder=11)\n", "\n", "# Draw all the collected plots\n", "plot.draw_collections([lcd, bc, tlc, tpc, sc,ldA,ldB,ldC,max_load,max_unbal], figsize=(20,20))" @@ -168,9 +169,10 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" + "pygments_lexer": "ipython3", + "version": "3.13.5" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/tutorials/ltds2pp_without_ssh.ipynb b/tutorials/ltds2pp_without_ssh.ipynb index 9551de1eb8..1eb0076c00 100644 --- a/tutorials/ltds2pp_without_ssh.ipynb +++ b/tutorials/ltds2pp_without_ssh.ipynb @@ -6,6 +6,28 @@ "metadata": {}, "source": " # Converting a grid model from LTDS to pandapower\n" }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Pandapower with UK Power Networks\n", + "\n", + "This tutorial shows some functionalities and studies that can be performed using the power flow capabilities of pandapower. It will demonstrate how to run power flow simulations in pandapower, how to analyse the grid and to investigate different use cases relying on the power flow engine of pandapower.\n", + "\n", + "This tutorial has been created in collaboration with UK Power Networks (UKPN), the Distribution System Operator owning and operating the electricity network across London, the South East and the East of England.\n", + "\n", + "The tutorial will use the real grids associated with the three licensed electricity distribution networks operated by UKPN (LPN, SPN and EPN). It will provide some examples of how pandapower can be used to run investigations and analyses using the open source data released by UKPN. UK Power Networks has provided this grid data as part of their LTDS CIM dataset release. It is a \"Shared\" dataset that requires special access. To request access:\n", + "\n", + "· Register and login to the UKPN Open Data Portal [ukpowernetworks.opendatasoft.com]\n", + "\n", + "· Visit the LTDS CIM [ukpowernetworks.opendatasoft.com] page and complete the Shared Data Request Form [ukpowernetworks.opendatasoft.com]\n", + "\n", + "\n", + "\n", + "Once approved, CIM data is published as XML file attachments (one per licence area: EPN, SPN, LPN). You can download the XML files directly from the portal." + ], + "id": "d8960f8e8cf5642a" + }, { "cell_type": "markdown", "id": "25b136f3", @@ -42,7 +64,7 @@ "source": [ "## LTDS to pandapower\n", "\n", - "First, we define the LTDS zip archive, which can be converted to pandapower. If there is no SSH profile available, there is an option to get some P and Q values from Excel. However, please take into account that there are some assumptions made, which can..." + "First, we define the LTDS zip archive, which can be converted to pandapower. If there is no SSH profile available, there is an option to get some P and Q values from Excel. However, please take into account that there are some assumptions made, which limit the applicability of the data and make them unsuitable for all use cases." ] }, { @@ -56,51 +78,99 @@ "# ltds_files is a list containing paths to files needed for the LTDS converter:\n", "ltds_files = [r\"path-to-ltds-zip\"]\n", "path_excel = [r\"path-to-excel-input-files\"]\n", - "excel_sheet_name = 'Table 3A - Load Data Observed'\n", - "excel_column_name = 'Maximum Demand 2023/24'\n", + "excel_column_name = 'Maximum Demand 2024/25'\n", + "# the Excel data provides the maximum demand / generation. If you want to assume a specific loading,\n", + "# choose a scaling_factor between 0.1 and 1.0\n", + "scaling_factor = 1.0\n", "\n", "cim_parser = CimParser(cgmes_version='ltds')\n", "cim_parser.parse_files(ltds_files).prepare_cim_net().set_cim_data_types()\n", "cim = cim_parser.cim\n", "\n", - "\n", - "excel_df = []\n", + "excel_df_demand = []\n", + "excel_df_generation = []\n", "for one_excel_file in path_excel:\n", " if not os.path.isfile(one_excel_file):\n", " continue\n", - " one_excel_df = pd.read_excel(one_excel_file, sheet_name=excel_sheet_name, skiprows=1)\n", - " one_excel_df = one_excel_df.loc[one_excel_df['Season'] == 'Summer']\n", - " one_excel_df = one_excel_df[['Sub-station', excel_column_name]]\n", - " one_excel_df = one_excel_df.rename(columns={'Sub-station': 'name_excel', excel_column_name: 'p_mw_excel'})\n", - " one_excel_df['p_mw_excel'] = one_excel_df['p_mw_excel'].astype(float)\n", - " excel_df.append(one_excel_df)\n", - "if len(excel_df) > 0:\n", - " excel_df = pd.concat(excel_df)\n", - " excel_df = excel_df.drop_duplicates(subset=['name_excel'])\n", + " one_excel_dict = pd.read_excel(one_excel_file, sheet_name=None, skiprows=1)\n", + " for one_excel_sheet, one_excel_df in one_excel_dict.items():\n", + " if 'Table 3A' in one_excel_sheet:\n", + " # demand data\n", + " excel_df_demand.append(one_excel_df)\n", + " elif 'Table 5' in one_excel_sheet:\n", + " # generation data\n", + " excel_df_generation.append(one_excel_df)\n", + "# prepare the demand data\n", + "if len(excel_df_demand) > 0:\n", + " excel_df_demand = pd.concat(excel_df_demand)\n", + " if 'Season' in excel_df_demand:\n", + " excel_df_demand = excel_df_demand.loc[excel_df_demand['Season'] == 'Winter']\n", + " excel_df_demand = excel_df_demand.rename(columns={'Substation MRID': 'name_excel', excel_column_name: 'p_mw_excel'})\n", + " if 'name_excel' not in excel_df_demand or 'p_mw_excel' not in excel_df_demand:\n", + " # the Excel document is not valid\n", + " excel_df_demand = pd.DataFrame(columns=['name_excel', 'p_mw_excel'])\n", + " excel_df_demand = excel_df_demand[['name_excel', 'p_mw_excel']]\n", + " excel_df_demand['p_mw_excel'] = excel_df_demand['p_mw_excel'].astype(float)\n", + " excel_df_demand = excel_df_demand.dropna(how='any')\n", + " excel_df_demand = excel_df_demand.groupby('name_excel', as_index=False)['p_mw_excel'].sum()\n", "else:\n", - " excel_df = pd.DataFrame(columns=['name_excel', 'p_mw_excel'])\n", - "\n", - "cim['eq']['EnergyConsumer']['description'] = cim['eq']['EnergyConsumer']['description'].str.replace('11 kV', '11kV')\n", - "\n", - "cim['eq']['EnergyConsumer']['description'] = cim['eq']['EnergyConsumer']['description'].astype(str)\n", - "\n", - "cim['eq']['EnergyConsumer']['dups'] = cim['eq']['EnergyConsumer'].groupby('description')['description'].transform('count')\n", + " excel_df_demand = pd.DataFrame(columns=['name_excel', 'p_mw_excel'])\n", + "# prepare the generation data\n", + "if len(excel_df_generation) > 0:\n", + " excel_df_generation = pd.concat(excel_df_generation)\n", + " if 'Connected / Accepted' in excel_df_generation:\n", + " excel_df_generation = excel_df_generation.loc[excel_df_generation['Connected / Accepted'] == 'Connected']\n", + " excel_df_generation = excel_df_generation.rename(columns={'Substation MRID': 'name_excel', 'Installed Capacity': 'p_mw_excel'})\n", + " if 'name_excel' not in excel_df_generation or 'p_mw_excel' not in excel_df_generation:\n", + " # the Excel document is not valid\n", + " excel_df_generation = pd.DataFrame(columns=['name_excel', 'p_mw_excel'])\n", + " excel_df_generation['p_mw_excel'] = excel_df_generation['p_mw_excel'].astype(float)\n", + " excel_df_generation = excel_df_generation.dropna(how='any')\n", + " excel_df_generation = excel_df_generation.groupby('name_excel', as_index=False)['p_mw_excel'].sum()\n", + "else:\n", + " excel_df_generation = pd.DataFrame(columns=['name_excel', 'p_mw_excel'])\n", "\n", - "# add the loads to the SSH\n", + "# add the loads to the SSH profile\n", "cim['ssh']['EnergyConsumer'] = pd.concat([cim['ssh']['EnergyConsumer'], cim['eq']['EnergyConsumer'][['rdfId']]], ignore_index=True)\n", - "cim['ssh']['EnergyConsumer']['p'] = cim['eq']['EnergyConsumer']['description'].map(excel_df.set_index('name_excel')['p_mw_excel']) / cim['eq']['EnergyConsumer']['dups']\n", - "cim['ssh']['EnergyConsumer']['p'] = cim['ssh']['EnergyConsumer']['p'].fillna(0.)\n", + "# get the Substation ID\n", + "sub = cim['eq']['Terminal'][['ConnectivityNode', 'ConductingEquipment']]\n", + "sub = sub.rename(columns={'ConnectivityNode': 'rdfId'})\n", + "sub = pd.merge(sub, cim['eq']['ConnectivityNode'][['rdfId', 'ConnectivityNodeContainer']], how='left', on='rdfId')\n", + "sub = sub.drop(columns=['rdfId']).drop_duplicates(subset=['ConductingEquipment'])\n", + "# adding substations to EnergyConsumer\n", + "cim['ssh']['EnergyConsumer']['sub'] = cim['ssh']['EnergyConsumer']['rdfId'].map(\n", + " sub.set_index('ConductingEquipment')['ConnectivityNodeContainer'])\n", + "# identify duplications\n", + "cim['ssh']['EnergyConsumer']['dups'] = cim['ssh']['EnergyConsumer'].groupby('sub')['sub'].transform('count')\n", + "cim['ssh']['EnergyConsumer']['p'] = cim['ssh']['EnergyConsumer']['sub'].map(\n", + " excel_df_demand.set_index('name_excel')['p_mw_excel']) / cim['ssh']['EnergyConsumer']['dups']\n", + "cim['ssh']['EnergyConsumer']['p'] = cim['ssh']['EnergyConsumer']['p'].fillna(0.) * scaling_factor\n", "cim['ssh']['EnergyConsumer']['q'] = cim['ssh']['EnergyConsumer']['q'].fillna(0.)\n", "cim['ssh']['EnergyConsumer']['inService'] = cim['ssh']['EnergyConsumer']['inService'].fillna(True)\n", + "cim['ssh']['EnergyConsumer'] = cim['ssh']['EnergyConsumer'].drop(columns=['sub', 'dups'])\n", + "\n", + "# add the generation to the SSH profile\n", + "for one_asset in ['SynchronousMachine', 'PowerElectronicsConnection']:\n", + " cim['ssh'][one_asset] = pd.concat([cim['ssh'][one_asset], cim['eq'][one_asset][['rdfId']]], ignore_index=True)\n", + " # adding substations to generators\n", + " cim['ssh'][one_asset]['sub'] = cim['ssh'][one_asset]['rdfId'].map(sub.set_index('ConductingEquipment')['ConnectivityNodeContainer'])\n", + " # identify duplications\n", + " cim['ssh'][one_asset]['dups'] = cim['ssh'][one_asset].groupby('sub')['sub'].transform('count')\n", + " cim['ssh'][one_asset]['p'] = cim['ssh'][one_asset]['sub'].map(\n", + " excel_df_generation.set_index('name_excel')['p_mw_excel']) / cim['ssh'][one_asset]['dups']\n", + " cim['ssh'][one_asset]['p'] = cim['ssh'][one_asset]['p'].fillna(0.)\n", + " cim['ssh'][one_asset]['q'] = cim['ssh'][one_asset]['q'].fillna(0.)\n", + " cim['ssh'][one_asset]['inService'] = cim['ssh'][one_asset]['inService'].fillna(True)\n", + " cim['ssh'][one_asset] = cim['ssh'][one_asset].drop(columns=['sub', 'dups'])\n", "\n", "for one_sw in ['Breaker', 'Disconnector', 'Switch', 'LoadBreakSwitch']:\n", " cim['ssh'][one_sw] = pd.concat([cim['ssh'][one_sw], cim['eq'][one_sw][['rdfId', 'normalOpen']].rename(columns={'normalOpen': 'open'})], ignore_index=True)\n", " cim['ssh'][one_sw]['inService'] = True\n", "\n", "for one_asset in ['ExternalNetworkInjection', 'ConformLoad', 'NonConformLoad', 'StationSupply',\n", - " 'SynchronousMachine', 'AsynchronousMachine', 'EquivalentInjection', 'PowerElectronicsConnection']:\n", + " 'AsynchronousMachine', 'EquivalentInjection']:\n", " cim['ssh'][one_asset] = pd.concat([cim['ssh'][one_asset], cim['eq'][one_asset][['rdfId']]], ignore_index=True)\n", - " cim['ssh'][one_asset]['p'] = cim['ssh'][one_asset]['p'].fillna(0.)\n", + " cim['ssh'][one_asset]['p'] = cim['ssh'][one_asset]['p'].fillna(0.) * scaling_factor\n", " cim['ssh'][one_asset]['q'] = cim['ssh'][one_asset]['q'].fillna(0.)\n", " cim['ssh'][one_asset]['inService'] = cim['ssh'][one_asset]['inService'].fillna(True)\n", "\n", diff --git a/tutorials/plotting_basic.ipynb b/tutorials/plotting_basic.ipynb index 67fd681f40..89f26cc827 100644 --- a/tutorials/plotting_basic.ipynb +++ b/tutorials/plotting_basic.ipynb @@ -10,7 +10,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": true + "collapsed": true, + "jupyter": { + "outputs_hidden": true + } }, "source": [ "This tutorial shows you how to plot pandapower networks. \n", @@ -31,14 +34,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2025-10-20T09:19:56.040624Z", - "start_time": "2025-10-20T09:19:52.392741Z" + "end_time": "2026-03-25T16:09:05.952159Z", + "start_time": "2026-03-25T16:09:05.946941Z" } }, - "outputs": [], "source": [ "import numpy as np\n", "import geojson\n", @@ -49,24 +50,60 @@ "from pandapower.run import runpp\n", "from pandapower.plotting.plotting_toolbox import get_collection_sizes\n", "from pandapower.plotting import create_line_collection, create_bus_collection, draw_collections, create_annotation_collection" - ] + ], + "outputs": [], + "execution_count": 2 }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2025-10-20T09:19:59.411100Z", - "start_time": "2025-10-20T09:19:56.045609Z" + "end_time": "2026-03-25T16:09:37.718797Z", + "start_time": "2026-03-25T16:09:37.064636Z" } }, - "outputs": [], "source": [ - "# load example net (IEEE 9 buses)\n", + "# load example net (MV Oberrhein)\n", "net = mv_oberrhein()\n", "# simple plot of net with existing geocoordinates or generated artificial geocoordinates\n", "simple_plot(net, show_plot=True)" - ] + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "numba cannot be imported and numba functions are disabled.\n", + "Probably the execution is slow.\n", + "Please install numba to gain a massive speedup.\n", + "(or if you prefer slow execution, set the flag numba=False to avoid this warning!)\n" + ] + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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EP7SS9K//+1OKDh40H1PKldOqluXzC4fK9cLReZCKI4DeGGv2zfccIPeeustffLJJ7rrrruc2nSkjmcGIbdq1Sonvd2amOWnZWhY2SzzYNLbs2dPUePGFKQB8cAa7LRrt1bjx9f2fIFuc6gbNVJMO3XqlObOnetMqrDO7FazeNVVV9H8LROz5nHXXBPts4hdoagStDT3CFYbApewMcu9e/fW8OHDnY287t27X5IpMm6cNH68ZU65HytdWrrxRummm9xGkVkFATpC6uTJk5o0aZJTH3jFFVfw7IaZvbZZHZ/vhSxQtgvXosVC7d1biXRRIIatX7/eqd/bt2+fSpf+nfbuzafk5ISA09sfekgxvQCxaNEiffvttzp9+rSaN2+uli1bOhd1QFZfwChcOLgdcCtls6awQDSVKVNGXbt21YQJE5zrTnudt7GLlkVjJS2WRZM9+7meK/b3MWPc0qz773cnGuTLl/m/h3RxR0h9/vnnTmO4+++/n666EfK3v0l//3v6899Tl6JChc7qz39+VSdPHnbG4NmoImvgQQopEJodq7lz3c7JNv/ZGo1b2m6zZv6n7lpA/tVXX2ndunWqVKmSs5u8fn1ZXXedewHj7wSHbNlS1L59giZNcptyxRIrh7LMK+vMfvDgQTVo0EBt27ZlZi5wnt/+VnrxRe/NIi3I37WLWeiIjdd8a/hpWVK1arXWU0+11vLlCRn+bGfLJjVuLE2eLJUooUyNAB0hYxeQ1sinR48eutLyKBER1tnVnm574w30jXv4cGnQoCT99NNP+u6777R3715ddtllTqBuWRDZ7NUQQECOHJHef9+trV23zv2Y/Sr5FtHq1HF3sgcMSPti2bKRZs2a5cz4LlSokNMB1xrs+BbPPv9cuuUWd1cso8W5hIQUXX75dv3wQwmVLBlbu9FbtmxxLtS2b9+u6tWrq127diptOY0ALmCvJTVqeHtSbFHugQekF17gSUXsmD59vm69taL27i2l5GT/rjezZ5caNpS+/TbtnXRrLGlTC2xh3KZA1KollS2ruEKAjpBITEzUa6+9plKlSql///7swEbYqlVu/Z4FBv52ZH/iCXf3/fwVTVtksUDdLpqLFSumFi1aOLtZOekiB/hlxQqpY0dp507f79WlX+PbPbd006+/lqpVO/e55ORkJ817xowZOnv2rFq1auX8HqbWTGfe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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 3 }, { "cell_type": "markdown", @@ -76,6 +113,81 @@ "\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Simple Highlighting Plot\n", + "\n", + "The function ``simple_hl_plot()`` highlights lines or buses in a simple network plot. The highlighted elements are displayed in red and enlarged. \n", + "Additionally, buses and lines can be located directly in the plot by hovering the mouse over a specific line or bus. \n", + "The name and index will be shown in a small box. The simple_hl_plot function can be called as follows:" + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-25T16:09:43.509910Z", + "start_time": "2026-03-25T16:09:42.937706Z" + } + }, + "source": [ + "from pandapower.plotting.simple_plot import simple_plot\n", + "\n", + "# load example net (MV Oberrhein)\n", + "net = mv_oberrhein()\n", + "# we want to highlight overhead power liens and connected buses\n", + "ol_lines = net.line.loc[net.line.type==\"ol\"].index\n", + "ol_buses = net.bus.index[net.bus.index.isin(net.line.from_bus.loc[ol_lines]) |\n", + " net.bus.index.isin(net.line.to_bus.loc[ol_lines])]\n", + "\n", + "simple_plot(net, highlight_lines=ol_lines, highlight_buses=ol_buses, enable_hover=True)" + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "numba cannot be imported and numba functions are disabled.\n", + "Probably the execution is slow.\n", + "Please install numba to gain a massive speedup.\n", + "(or if you prefer slow execution, set the flag numba=False to avoid this warning!)\n" + ] + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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r0nNm4ef2zOg9m62x6mddVaORo2SLrooR/369dP333+/38d56y03z4slSA9r913C//jj0rZtsf7PAAAAACC5cvKyvcsY8uXA/IEHHtD555+v4447LiufqWHDhmn48OE677zz1KpVq+Bj/okbPVp69llp5kxp3TqpenXJn775Zqfvh/f1nPdXXnlFixYt0hVXXKFDDz00aJCXH0+dO/NMacaM2Ea7+ZC+3zvvJK8rPAAAAADEigC9kHv22WdVsWJFXX755co2X3zxhT788EN17NhR7dq1i+sYW7Zs0YsvvhgE6ZUqVdLhhx8eZA40bNgwqMHf0/r1Ycp6376xPY7T7i+5RMEOfwFrAAAAAACQMgTohdyIESOC269+9augLj1bTJgwQQMGDAgCcwfoidixY0eQ5u56+xkzZmjFihUqWbKk6tevr8aNGwe3GjVqBF+7dq1Up460aVN8j/X++06xT+h0AQAAACAuBOiFnMesPfnkk+revXuwu5wNpk6dqjfeeCNIu3dqe0Fp6fFYuXJlEKj7Nnfu3CCAr169ehCojx59vP785xrKy4v9Mb2+4XFr776b1NMFAAAAgKgQoBdybiHwyCOPBMH5ueeem+nT0ezZs9W3b181bdpUXbt2VYn9tWxP0NatWzVnzpxgd3369Bn617+u1ooV3k2Pb1HAawlz50qHHpr0UwUAAACAAtHFvZDz7rRrsh2gZrrf34IFC/Taa68FM9ovvvjilAfn5jp0LwZ06dJF3bvfrRUrasYdnJufwmHDknqKAAAAABAVAvQiwAH62rVrtWTJkoydw7Jly4KO67Vr19Zll10W1Iin2+rViafSe01h1aqknA4AAAAAxIQAvQjwjrV3kr2LngmrV6/Wyy+/HHRZv/LKK/fZXT0dktEjzzvopUsn42wAAAAAIDYE6EWAd6tdgz5t2rS0P/b69ev10ksvBR3ke/TooXLlyilTDjww3AFPNECvWzdZZwQAAAAA0SNAL0Jp7gsXLtS6devS9pibNm1Snz59tH37dl111VXBDnomVa4snXNOYjvp/i907pzMswIAAACA6BCgFxEeMeaGcR49lg7unv7qq68Gte/eOa9WrZqywR13SNu3x3dfl83feKNUvnyyzwoAAAAA9o8AvYgoX7686tWrl5Y6dM8d95zzxYsXB/PXD3RueZbo1EnyOPh4e9Tddluy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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 4 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We get an interactive plot with hover functionality that display overhead power lines and connected buses enlarged in red." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -346,8 +458,7 @@ "ExecuteTime": { "end_time": "2025-10-20T09:20:05.165906Z", "start_time": "2025-10-20T09:20:04.409851Z" - }, - "scrolled": false + } }, "outputs": [], "source": [ @@ -403,9 +514,10 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" + "pygments_lexer": "ipython3", + "version": "3.11.0" } }, "nbformat": 4, - "nbformat_minor": 1 + "nbformat_minor": 4 } diff --git a/tutorials/ukpn_pp_power_flow.ipynb b/tutorials/ukpn_pp_power_flow.ipynb new file mode 100644 index 0000000000..ae307593e0 --- /dev/null +++ b/tutorials/ukpn_pp_power_flow.ipynb @@ -0,0 +1,982 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f827a7c8", + "metadata": {}, + "source": [ + "### Pandapower with UK Power Networks\n", + "\n", + "This tutorial shows some functionalities and studies that can be performed using the power flow capabilities of pandapower. \n", + "It will demonstrate how to run power flow simulations in pandapower, how to perform grid analyses and investigate different use cases relying on the power flow engine of pandapower.\n", + "\n", + "This tutorial has been created in collaboration with UK Power Networks, the Distribution System Operator owning and operating the electricity network across London, the South East and the East of England.\n", + "\n", + "The tutorial will use the real grids associated with the three licensed electricity distribution networks operated by UK Power Networks (LPN, SPN and EPN).\n", + "It will provide some examples of how pandapower can be used to run investigations and analyses using the open source data released by UK Power Networks.\n", + "\n", + "UK Power Networks has provided the grid data as part of their LTDS CIM dataset release. It is a \"Shared\" dataset that requires special access. To request access, visit the [LTDS CIM](https://ukpowernetworks.opendatasoft.com/explore/dataset/ukpn-ltds-cim/information/) page and complete the [Shared Data Request Form](https://ukpowernetworks.opendatasoft.com/login/?next=/explore/forms/cim-access-request-form/). Once approved, CIM data is published as XML file attachments (one per licence area: EPN, SPN, LPN). You can download the XML files directly from the portal.\n", + "\n", + "The additional data required to integrate load and generation in the grid are openly available as Excel tables at the following links: \n", + "- EPN --> [EPN Long Term Development Statement - November 2025](https://ukpowernetworks.sharepoint.com/sites/OpenDataPortalLibrary/Shared%20Documents/Forms/AllItems.aspx?id=%2Fsites%2FOpenDataPortalLibrary%2FShared%20Documents%2FGeneral%2FLong%20Term%20Development%20Statement%2FNovember%202025%2FEPN%20Long%20Term%20Development%20Statement%20%2D%20November%202025&p=true&ga=1)\n", + "- SPN --> [SPN Long Term Development Statement - November 2025](https://ukpowernetworks.sharepoint.com/sites/OpenDataPortalLibrary/Shared%20Documents/Forms/AllItems.aspx?id=%2Fsites%2FOpenDataPortalLibrary%2FShared%20Documents%2FGeneral%2FLong%20Term%20Development%20Statement%2FNovember%202025%2FSPN%20Long%20Term%20Development%20Statement%20%2D%20November%202025&p=true&ga=1)\n", + "- LPN --> [LPN Long Term Development Statement - November 2025](https://ukpowernetworks.sharepoint.com/sites/OpenDataPortalLibrary/Shared%20Documents/Forms/AllItems.aspx?id=%2Fsites%2FOpenDataPortalLibrary%2FShared%20Documents%2FGeneral%2FLong%20Term%20Development%20Statement%2FNovember%202025%2FLPN%20Long%20Term%20Development%20Statement%20%2D%20November%202025&p=true&ga=1)\n" + ] + }, + { + "cell_type": "code", + "id": "f8143d76", + "metadata": {}, + "source": [ + "# Import the needed libraries \n", + "import pandapower as pp\n", + "import pandas as pd\n", + "import numpy as np\n", + "import os" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "0b138dc1", + "metadata": {}, + "source": [ + "#### Import of the UK Power Network grids\n", + "This tutorial assumes that the grids of UK Power Networks have been already imported from the CIM data and saved as pandapower networks in json format. \n", + "To see how to import the UK Power Networks grids starting from the CIM files downloadable from the UK Power Networks portal, please refer to the following [UKPN_CIM2pp_tutorial](). \n", + "Here you can also find how to save the pandapower grid into a json file and how to navigate through the pandapower grid data or the attributes of the different grid components. " + ] + }, + { + "cell_type": "code", + "id": "bc1f9953", + "metadata": {}, + "source": [ + "# Import the grid for the analysis\n", + "filename = \"LPN EQ SSH_0329_eq.json\" # Give here the name of the json file with the UKPN grid you want to use\n", + "if os.path.isfile(filename):\n", + " net = pp.from_json(filename)\n", + "else:\n", + " print(\"file does not exist, creating a dummy net\")\n", + " net = pp.create_empty_network()\n", + " bus = pp.create_bus(net, vn_kv=132)\n", + " pp.create_ext_grid(net, bus=bus)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "8516cb5c", + "metadata": {}, + "source": [ + "#### Workarounds for power flow execution\n", + "The following blocks of code provide some functions to apply some workarounds necessary to run successfully the power flow on the UK Power Networks grids.\n", + "These workarounds include, for example, the creation of external grids (*slack buses* in the power flow terminology) or the replacement of zero impedance components with switches. " + ] + }, + { + "cell_type": "code", + "id": "5e226201", + "metadata": {}, + "source": [ + "# Function to replace components with very small impedance with switches.\n", + "def _replace_zero_impedance_components(net):\n", + " min_ohm = 0.001\n", + " to_replace = (np.abs(net.line.x_ohm_per_km * net.line.length_km) <= min_ohm) & net.line.in_service\n", + "\n", + " if np.any(to_replace):\n", + " print(f\"replaced {sum(to_replace)} lines with switches\")\n", + "\n", + " for i in net.line.loc[to_replace].index.values:\n", + " pp.create_replacement_switch_for_branch(net, \"line\", i)\n", + " net.line.at[i, \"in_service\"] = False\n", + "\n", + " xward = net.xward.loc[(np.abs(net.xward.x_ohm) <= min_ohm) & net.xward.in_service].index.values\n", + " if len(xward) > 0:\n", + " pp.replace_xward_by_ward(net, index=xward, drop=False)\n", + " print(f\"replaced {len(xward)} xwards with wards\")\n", + "\n", + " zb_f_ohm = np.square(net.bus.loc[net.impedance.from_bus.values, \"vn_kv\"].values) / net.impedance.sn_mva\n", + " zb_t_ohm = np.square(net.bus.loc[net.impedance.to_bus.values, \"vn_kv\"].values) / net.impedance.sn_mva\n", + " impedance = ((np.abs(net.impedance.xft_pu) <= min_ohm / zb_f_ohm) |\n", + " (np.abs(net.impedance.xtf_pu) <= min_ohm / zb_t_ohm)) & net.impedance.in_service\n", + "\n", + " if any(impedance):\n", + " print(f\"replaced {sum(impedance)} impedance elements with switches\")\n", + "\n", + " for i in net.impedance.loc[impedance].index.values:\n", + " pp.create_replacement_switch_for_branch(net, \"impedance\", i)\n", + " net.impedance.at[i, \"in_service\"] = False" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "3fa5413f", + "metadata": {}, + "source": [ + "# Function to apply the needed workarounds\n", + "def apply_workarounds(net, license_area):\n", + " net.impedance.drop(net.impedance.index, inplace=True)\n", + " _replace_zero_impedance_components(net)\n", + " net.line[\"c_nf_per_km\"] *= 0.1\n", + "\n", + " if license_area == \"LPN\":\n", + " pp.create_ext_grid(net,bus=10711,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=10699,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=10674,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=10738,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=10673,vm_pu=1)\n", + " elif license_area == \"SPN\":\n", + " pp.create_ext_grid(net,bus=4899,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4879,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4903,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4920,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4916,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4925,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=4878,vm_pu=1)\n", + " elif license_area == \"EPN\":\n", + " pp.create_ext_grid(net,bus=9906,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=9918,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=9900,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=9910,vm_pu=1)\n", + " pp.create_ext_grid(net,bus=9878,vm_pu=1)\n", + " else:\n", + " raise Exception(\"Sorry, this license area does not exist in UK Power Networks. Allowed areas are LPN, SPN and EPN.\")\n", + "\n", + " return net\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "871b5ee9", + "metadata": {}, + "source": [ + "# Apply the workarounds on the selected grid\n", + "license_area = \"LPN\" # Provide here the name of the considered license area. It should be \"LPN\", \"SPN\", or \"EPN\".\n", + "if net.bus.index.size > 1:\n", + " net = apply_workarounds(net, license_area)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "16b83fff", + "metadata": {}, + "source": [ + "### Contingency analysis: run a power flow study\n", + "One of the easiest tasks that can be done with pandapower is to run a power flow. \n", + "This allows analysing the voltage conditions in the grid and the powers/currents flowing through the different lines and components of the network, given the load and generation available as input. \n", + "\n", + "Through a power flow calculation it is possible to make a contingency analysis, namely to assess if the operating conditions of the grid are within the allowed boundaries\n", + "\n", + "In this section, you will see: \n", + "- How to run a power flow and visualize the results\n", + "- How to filter the power flow results\n", + "- How to identify possible contingencies (overloading or voltage violations)\n", + "\n" + ] + }, + { + "cell_type": "code", + "id": "cf5909be", + "metadata": {}, + "source": [ + "# Run a power flow\n", + "pp.runpp(net)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "1b0e9ce0", + "metadata": {}, + "source": [ + "In the bus results table you will find the resulting bus voltage and power consumption / injection at each bus" + ] + }, + { + "cell_type": "code", + "id": "f74e47d7", + "metadata": {}, + "source": [ + "# Visualize bus results\n", + "display(net.res_bus)\n", + "display(\"Maximum voltage magnitude in the grid (per unit): \" + str(np.nanmax(net.res_bus.vm_pu)))\n", + "display(\"Minimum voltage magnitude in the grid (per unit): \" + str(np.nanmin(net.res_bus.vm_pu)))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "fb96a59d", + "metadata": {}, + "source": [ + "Some of the bus results may have NaN. If this happens, it means that the bus is disconnected from the main grid." + ] + }, + { + "cell_type": "code", + "id": "04a95d21", + "metadata": {}, + "source": [ + "# Visualize number of connected buses\n", + "num_disconnected_buses = np.sum(np.isnan(net.res_bus.vm_pu))\n", + "num_connected_buses = np.sum(~np.isnan(net.res_bus.vm_pu))\n", + "total_num_buses = len(net.bus)\n", + "percentage_connected_buses = 100 * num_connected_buses / total_num_buses\n", + "display(\"Percentage of connected buses: \" + str(percentage_connected_buses))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "40f8110b", + "metadata": {}, + "source": [ + "In the line and transformer result tables you can see, among others, the level of power flowing through these components." + ] + }, + { + "cell_type": "code", + "id": "e3b9f49a", + "metadata": {}, + "source": [ + "# Visualize line results\n", + "display(net.res_line)\n", + "display(\"Maximum active power in the lines (in MW): \" + str(net.res_line.loc[net.res_line.p_from_mw.notna(), 'p_from_mw'].max()))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "5c9d085a", + "metadata": {}, + "source": [ + "# Visualize transformer results\n", + "display(net.res_trafo)\n", + "display(\"Maximum active power in the transformers (in MW): \" + str(net.res_trafo.loc[net.res_trafo.p_hv_mw.notna(), 'p_hv_mw'].max()))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "76abe03b", + "metadata": {}, + "source": [ + "You can easily sort the results using the *sort_values* function\n" + ] + }, + { + "cell_type": "code", + "id": "a054765b", + "metadata": {}, + "source": [ + "# Sort bus results from buses with the smallest voltage\n", + "net.res_bus.sort_values(\"vm_pu\").head(20)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "2663f6a1", + "metadata": {}, + "source": [ + "# Sort line results from lines with highest active power flow\n", + "net.res_line.sort_values(\"p_from_mw\", ascending=False).head(20)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "4a574a91", + "metadata": {}, + "source": [ + "You can visualize only the results for a specific element" + ] + }, + { + "cell_type": "code", + "id": "7d34f73c", + "metadata": {}, + "source": [ + "# Visualize bus results at bus 50\n", + "if 50 in net.res_bus.index:\n", + " print(net.res_bus.loc[50])\n", + "else:\n", + " print(\"The given bus does not exist\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "f65977c7", + "metadata": {}, + "source": [ + "# Visualize results for transformer 15\n", + "if 15 in net.res_trafo.index:\n", + " print(net.res_trafo.loc[15])\n", + "else:\n", + " print(\"The given trafo does not exist\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "9aeba512", + "metadata": {}, + "source": [ + "You can filter the results as you like, selecting only specific types or clusters of elements, or specific columns of the tables" + ] + }, + { + "cell_type": "code", + "id": "aac2a8b4", + "metadata": {}, + "source": [ + "# Visualize bus results only for buses at 132 kV\n", + "net.res_bus[net.bus.vn_kv==132]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "b537cc89", + "metadata": {}, + "source": [ + "# Visualize transformer results only for 132 kV/33 kV transformers \n", + "net.res_trafo[(net.trafo.vn_hv_kv==132) & (net.trafo.vn_lv_kv==33)]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "f6635739", + "metadata": {}, + "source": [ + "# Visualize bus results only for a desired zone (zones can be seen at net.bus.zone)\n", + "net.res_bus[net.bus.zone==\"Dartford Grid\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "e967ecc0", + "metadata": {}, + "source": [ + "# Visualize only active and reactive powers of the lines\n", + "net.res_line[[\"p_from_mw\", \"q_from_mvar\", \"p_to_mw\", \"q_to_mvar\"]]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "f73d24eb", + "metadata": {}, + "source": [ + "You can easily identify possible voltage contingencies in the grid, namely voltage values beyond the allowed thresholds. " + ] + }, + { + "cell_type": "code", + "id": "2f2b92d1", + "metadata": {}, + "source": [ + "# Check possible voltage violations\n", + "# Define voltage boundaries\n", + "lower_v_threshold = 0.90 # Define the lower boundary of the voltage magnitude\n", + "upper_v_threshold = 1.10 # Define the upper boundary of the voltage magnitude\n", + "\n", + "# Check for overvoltages\n", + "if np.any(net.res_bus.vm_pu > upper_v_threshold):\n", + " display(\"Overvoltages are present in the grid. Maximum voltage is: \" + str(np.nanmax(net.res_bus.vm_pu)))\n", + " buses_with_overvoltage = net.bus.index[net.res_bus.vm_pu>upper_v_threshold]\n", + "else: \n", + " display(\"No overvoltages are present in the grid\")\n", + "\n", + "# Check for undervoltages\n", + "if np.any(net.res_bus.vm_pu < lower_v_threshold):\n", + " display(\"Undervoltages are present in the grid. Minimum voltage is: \" + str(np.nanmin(net.res_bus.vm_pu)))\n", + " buses_with_undervoltage = net.bus.index[net.res_bus.vm_pu 100*overloading_factor):\n", + " display(\"Overloading present in the grid transformers. Maximum loading is: \" + str(np.nanmax(net.res_trafo.loading_percent)))\n", + "else: \n", + " display(\"No overloading is present in the grid transformers\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "85946914", + "metadata": {}, + "source": [ + "# Visualize transformer loading (results sorted by the largest loading)\n", + "net.res_trafo[[\"loading_percent\"]].sort_values(\"loading_percent\", ascending=False)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "ec3a2e34", + "metadata": {}, + "source": [ + "### Grid analysis and forecasting: impact of different operating conditions\n", + "\n", + "Pandapower allows easily modifying the default data to test different loading or generation levels. This is for example useful to analyse future scenarios or to perform grid analyses with forecasted values.\n", + "\n", + "In this section you will see: \n", + "- How to change load and generation values\n", + "- How to scale up or down specific categories of loads or generation" + ] + }, + { + "cell_type": "markdown", + "id": "9b764578", + "metadata": {}, + "source": [ + "A load or generation value, if desired, can be simply overwritten." + ] + }, + { + "cell_type": "code", + "id": "48402cc8", + "metadata": {}, + "source": [ + "# Change active power at load 0 (Note: the index of the load is not the same as the index of the bus to which it is connected)\n", + "load_index = 0\n", + "new_load_p = 0.87\n", + "net.load.loc[load_index, \"p_mw\"] = new_load_p\n", + "if np.isnan(net.load.loc[load_index, 'bus']):\n", + " net.load.loc[load_index, [\"bus\", \"q_mvar\", \"in_service\", \"scaling\"]] = [0, 0, True, 1]\n", + " net.load.bus = net.load.bus.astype(int)\n", + " net.load.in_service = net.load.in_service.astype(bool)\n", + "net.load.loc[load_index]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "befe0c87", + "metadata": {}, + "source": [ + "# Change active and reactive power at static generator 3\n", + "sgen_index = 7\n", + "new_sgen_p = 1.2\n", + "new_sgen_q = 0.2\n", + "net.sgen.loc[sgen_index, \"p_mw\"] = new_sgen_p\n", + "net.sgen.loc[sgen_index, \"q_mvar\"] = new_sgen_q\n", + "if np.isnan(net.sgen.loc[sgen_index, 'bus']):\n", + " net.sgen.loc[sgen_index, [\"bus\", \"in_service\", \"scaling\"]] = [0, True, 1]\n", + " net.sgen.bus = net.sgen.bus.astype(int)\n", + " net.sgen.in_service = net.sgen.in_service.astype(bool)\n", + "net.sgen.bus = net.sgen.bus.astype(int)\n", + "net.sgen.loc[sgen_index]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "ea762bb1", + "metadata": {}, + "source": [ + "# Run power flow with the new data\n", + "pp.runpp(net)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "05565fe1", + "metadata": {}, + "source": [ + "The resulting power at the bus with the modified load and sgen will now correspond to the modified values given in input" + ] + }, + { + "cell_type": "code", + "id": "edcf49b3", + "metadata": {}, + "source": [ + "# Visualize results at the buses with modified load\n", + "net.res_bus.loc[net.load.loc[load_index, \"bus\"]]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "eccfdd2d", + "metadata": {}, + "source": [ + "# Visualize results at the buses with modified sgen\n", + "net.res_bus.loc[net.sgen.loc[sgen_index, \"bus\"]]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "f0b20dfb", + "metadata": {}, + "source": [ + "It is possible also to scale up or down all loads/sgens, or a subset of them, using the *scaling* attribute available for both loads and static generators." + ] + }, + { + "cell_type": "code", + "id": "8b61c492", + "metadata": {}, + "source": [ + "# Scale all loads\n", + "net.load.scaling = 0.5 # this will scale down all loads to 50% of the power available in the p_mw and q_mvar fields.\n", + "net.load" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "af6a6a5f", + "metadata": {}, + "source": [ + "# Run power flow with the new data\n", + "pp.runpp(net)\n", + "# Visualize results at the buses with modified load --> NOTE: p_mw result will be scaled according to scaling factor used\n", + "net.res_bus.loc[net.load.loc[load_index, \"bus\"]]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "562c254b", + "metadata": {}, + "source": [ + "# Find to which zone each load belongs to\n", + "load_zone = net.bus.zone[net.load.bus]\n", + "# Apply a scaling factor only for the desired zone\n", + "net.load.loc[(load_zone==\"Fulham Palace Rd C\").values, \"scaling\"] = 0.7\n", + "net.load.loc[(load_zone==\"Fulham Palace Rd C\").values]\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "abc933d7", + "metadata": {}, + "source": [ + "If loads and generators are classified with different *types*, it is possible to apply different scaling factors for each *type*. This is for example useful to apply different scaling factor for different generation technologies (e.g., PV, wind, etc.) and to modify the load and generation for the different clusters at different time steps, during a time series simulation. " + ] + }, + { + "cell_type": "code", + "id": "413a9359", + "metadata": {}, + "source": [ + "# Visualize the generator type\n", + "net.sgen.type" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "b9841413", + "metadata": {}, + "source": [ + "# Change the scaling factor for a specific type of generation\n", + "net.sgen.loc[net.sgen.type==\"PV\", \"scaling\"] = 0.2" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "f206f3f8", + "metadata": {}, + "source": [ + "### Hosting capacity: impact of new load or generation connections\n", + "\n", + "Hosting capacity studies are a common use case that can be addressed leveraging the pandapower power flow libraries. The goal is to understand how much load or generation can be connected to a bus, before exceeding the allowed boundaries (voltage boundaries or overloading of the grid components).\n", + "\n", + "In this section you will see:\n", + "- How to add new loads or generators to the grid\n", + "- How to discover the maximum load or generation that can be added at a bus before exceeding the operational boundaries (i.e., voltage or overloading limits)" + ] + }, + { + "cell_type": "markdown", + "id": "ed5e5bbe", + "metadata": {}, + "source": [ + "A new load can be easily create with the *create_load\" function of pandapower. It requires defining the bus to which the load will be connected and its active and reactive power." + ] + }, + { + "cell_type": "code", + "id": "e47c69d0", + "metadata": {}, + "source": [ + "# Create a new load at the desired bus\n", + "load_bus = 3403\n", + "if load_bus not in net.bus.index:\n", + " # if the bus not exists, it needs to be created first\n", + " pp.create_bus(net, vn_kv=132, index=load_bus)\n", + "load_p = 0.2\n", + "load_q = 0.1\n", + "pp.create_load(net, bus=3403, p_mw=load_p, q_mvar=load_q)\n", + "net.load.tail(1)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "1efc23d8", + "metadata": {}, + "source": [ + "A new static generator can be easily create with the *create_sgen\" function of pandapower. It requires defining the bus to which the static generator will be connected and its active and reactive power." + ] + }, + { + "cell_type": "code", + "id": "f783f01c", + "metadata": {}, + "source": [ + "# Create a new sgen at the desired bus\n", + "sgen_bus = 6137\n", + "if sgen_bus not in net.bus.index:\n", + " # if the bus not exists, it needs to be created first\n", + " pp.create_bus(net, vn_kv=132, index=sgen_bus)\n", + "sgen_p = 0.5\n", + "sgen_q = 0\n", + "pp.create_sgen(net, bus=sgen_bus, p_mw=sgen_p, q_mvar=sgen_q)\n", + "net.sgen.tail(1)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "4c879a8d", + "metadata": {}, + "source": [ + "It is possible to run a hosting capacity study and understand how much load or generation can be connected to a particular node, by incrementing continuously the power (of the load or generator) till when the boundaries of interest are not exceeded.\n", + "\n", + "In this example, for simplicity, we will investigate how much load can be added to the desired bus before exceeding the loading capacity of the grid transformers." + ] + }, + { + "cell_type": "code", + "id": "fa96fab3", + "metadata": {}, + "source": [ + "hosting_bus = 18 # bus selected for the analysis\n", + "incremental_p_mw = 1 # incremental value of power\n", + "if hosting_bus not in net.bus.index:\n", + " print(\"The given bus does not exist\")\n", + " hosting_bus = net.bus.index[0]\n", + "load_index = pp.create_load(net, bus=hosting_bus, p_mw=0, q_mvar=0)\n", + "within_hosting_limit = True # boolean telling if we are still within inside the allowed boundary\n", + "\n", + "# Hosting capacity logic\n", + "while within_hosting_limit:\n", + " net.load.loc[load_index, \"p_mw\"] += incremental_p_mw\n", + " pp.runpp(net)\n", + " if np.any(net.res_trafo.loading_percent > 100) or net.trafo.index.size == 0:\n", + " within_hosting_limit = False\n", + " net.load.loc[load_index, \"p_mw\"] -= incremental_p_mw\n", + "\n", + "# Visualize maximum load that can be connected at the selected bus\n", + "display(\"Maximum load that can be connected at bus \" + str(load_index) + \" is \" + str(net.load.loc[load_index, \"p_mw\"]) + \" MW\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "6f3d84dc", + "metadata": {}, + "source": [ + "### Grid control: impact of different settings for controllable components \n", + "\n", + "The operating conditions of the grid can be changed in multiple ways, acting on controllable components. Pandapower allows manipulating controllable components (like switches, capacitor banks, transformers with tap changers, etc.) to test the impact of different settings.\n", + "\n", + "In this section you will see:\n", + "- How to change status of switches and evaluate the impact of different network topologies\n", + "- How to change tap position of transformers and assess the resulting impact\n", + "- How to connect or disconnect capacitor banks and assess the resulting impact" + ] + }, + { + "cell_type": "markdown", + "id": "174d4205", + "metadata": {}, + "source": [ + "**Switches** are among the components that can controlled to modify how the power flows through the grid, as their open or closed status will determine the final topology of the grid. In pandapower, the status of the switch can be modified simply by acting on its *closed* attribute" + ] + }, + { + "cell_type": "code", + "id": "295d4796", + "metadata": {}, + "source": [ + "# Visualize the attributes of a switch\n", + "switch_index = 0\n", + "if switch_index in net.switch.index:\n", + " print(net.switch.loc[switch_index])\n", + "else:\n", + " print(\"The given switch does not exist\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "7e5d3de5", + "metadata": {}, + "source": [ + "# Visualize if the switch is closed (closed attribute = True) or open (closed attribute = False)\n", + "if switch_index not in net.switch.index:\n", + " bus_idx_1 = pp.create_bus(net, vn_kv=132)\n", + " bus_idx_2 = pp.create_bus(net, vn_kv=132)\n", + " pp.create_switch(net, bus_idx_1, bus_idx_2, 'b', True, index=switch_index)\n", + "net.switch.loc[switch_index, \"closed\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "c223b93d", + "metadata": {}, + "source": [ + "# Change the status of a switch\n", + "net.switch.loc[switch_index, \"closed\"] = False # In this case, we are opening the switch\n", + "net.switch.loc[switch_index]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "f001f12a", + "metadata": {}, + "source": [ + "The **tap position of transformers** is another parameter that can be modified to affect the operating conditions of the grid. In particular, through the transformer tap position it is possible to modify the resulting voltage levels. " + ] + }, + { + "cell_type": "code", + "id": "175ce9c9", + "metadata": {}, + "source": [ + "# Visualize the attributes of a transformer\n", + "trafo_index = 0\n", + "if trafo_index in net.trafo.index:\n", + " print(net.trafo.loc[trafo_index])\n", + "else:\n", + " print(\"The given trafo does not exist\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "5776f67f", + "metadata": {}, + "source": [ + "# Visualize the main tap changer settings of a transformer\n", + "if trafo_index not in net.trafo.index:\n", + " bus1 = pp.create_bus(net, vn_kv=110, name=\"Bus 110kV-1\")\n", + " bus2 = pp.create_bus(net, vn_kv=20, name=\"Bus 20kV-2\")\n", + " trafo = pp.create_transformer(net, hv_bus=bus1, lv_bus=bus2, std_type=\"63 MVA 110/20 kV\", index=trafo_index)\n", + "net.trafo.loc[trafo_index, [\"tap_min\", \"tap_max\", \"tap_neutral\", \"tap_pos\"]]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "f545aee6", + "metadata": {}, + "source": [ + "# Visualize voltage at the transformer secondary bus before applying any change\n", + "pp.runpp(net)\n", + "net.res_bus.loc[net.trafo.loc[trafo_index,\"lv_bus\"], \"vm_pu\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "c757395d", + "metadata": {}, + "source": [ + "# Change the tap position of the selected transformer\n", + "net.trafo.loc[trafo_index, \"tap_pos\"] = 2 # In this case, we are forcing the transformer to have tap position 2\n", + "net.trafo.loc[trafo_index]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "de412fb8", + "metadata": {}, + "source": [ + "# Visualize voltage at the transformer secondary bus after applying the tap position change\n", + "pp.runpp(net)\n", + "net.res_bus.loc[net.trafo.loc[trafo_index,\"lv_bus\"], \"vm_pu\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "79055b38", + "metadata": {}, + "source": [ + "**Capacitor banks** (or, more in general, shunts) can also affect the operating conditions by bringing an injection of reactive power in the grid. In pandapower, it is possible to connect or disconnect shunts by acting on their \"in_service\" attribute" + ] + }, + { + "cell_type": "code", + "id": "759bdf2b", + "metadata": {}, + "source": [ + "# Visualize the attributes of a shunt\n", + "shunt_index = 1\n", + "if shunt_index in net.shunt.index:\n", + " print(net.shunt.loc[shunt_index])\n", + "else:\n", + " print(\"The given shunt does not exist\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "67b3ebf6", + "metadata": {}, + "source": [ + "# Visualize if the shunt is connected (in_service = True) or not (in_service = False)\n", + "if shunt_index not in net.shunt.index:\n", + " bus = pp.create_bus(net, vn_kv=110, name=\"Bus 110kV-1\")\n", + " pp.create_shunt(net, bus=bus, q_mvar=0.5, p_mw=0.0, index=shunt_index)\n", + "net.shunt.loc[shunt_index, \"in_service\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "ca2e81e0", + "metadata": {}, + "source": [ + "# Visualize voltage at the shunt bus before applying any change\n", + "pp.runpp(net)\n", + "net.res_bus.loc[net.shunt.loc[shunt_index,\"bus\"], \"vm_pu\"]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "b73e0f47", + "metadata": {}, + "source": [ + "# Change the status of the switch\n", + "if net.shunt.loc[shunt_index, \"in_service\"]:\n", + " net.shunt.loc[shunt_index, \"in_service\"] = False\n", + "else:\n", + " net.shunt.loc[shunt_index, \"in_service\"] = True\n", + "\n", + "net.shunt.loc[shunt_index]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "bfa2d777", + "metadata": {}, + "source": [ + "# Visualize voltage at the shunt bus after applying the change\n", + "pp.runpp(net)\n", + "net.res_bus.loc[net.shunt.loc[shunt_index,\"bus\"], \"vm_pu\"]" + ], + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}