diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index 7cb375542..7cf4110a1 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -27,3 +27,4 @@ Changes * Added a new reinforcement method that separate lv grids when the overloading is very high `#380 `_ * Move function to assign feeder to Topology class and add methods to the Grid class to get information on the feeders `#360 `_ * Added a storage operation strategy where the storage is charged when PV feed-in is higher than electricity demand of the household and discharged when electricity demand exceeds PV generation `#386 `_ +* Added an estimation of the voltage deviation over a cable when selecting a suitable cable to connect a new component `#411 `_ diff --git a/edisgo/config/config_grid_default.cfg b/edisgo/config/config_grid_default.cfg index f48c68d8f..1cd829259 100644 --- a/edisgo/config/config_grid_default.cfg +++ b/edisgo/config/config_grid_default.cfg @@ -50,6 +50,11 @@ upper_limit_voltage_level_6 = 0.2 upper_limit_voltage_level_5 = 5.5 upper_limit_voltage_level_4 = 20.0 +# from VDE-AR-N 4100 (VDE-AR-N 4100) Anwendungsregel: 2019-04, table 3 +lv_max_voltage_deviation = 0.03 +# from VDE-AR-N 4110 (VDE-AR-N 4110) Anwendungsregel: 2023-09, 5.3.2 Zulässige Spannungsänderung +mv_max_voltage_deviation = 0.02 + [disconnecting_point] # Positioning of disconnecting points: Can be position at location of most diff --git a/edisgo/config/config_timeseries_default.cfg b/edisgo/config/config_timeseries_default.cfg index bfb97351c..84c6074bc 100644 --- a/edisgo/config/config_timeseries_default.cfg +++ b/edisgo/config/config_timeseries_default.cfg @@ -88,16 +88,16 @@ lv_load_case_hp = 1.0 # =========================== # power factors used to generate reactive power time series for loads and generators -mv_gen = 0.9 -mv_load = 0.9 -mv_storage = 0.9 -mv_cp = 1.0 -mv_hp = 1.0 -lv_gen = 0.95 -lv_load = 0.95 -lv_storage = 0.95 -lv_cp = 1.0 -lv_hp = 1.0 +mv_generator = 0.9 +mv_conventional_load = 0.9 +mv_storage_unit = 0.9 +mv_charging_point = 1.0 +mv_heat_pump = 1.0 +lv_generator = 0.95 +lv_conventional_load = 0.95 +lv_storage_unit = 0.95 +lv_charging_point = 1.0 +lv_heat_pump = 1.0 [reactive_power_mode] @@ -105,16 +105,16 @@ lv_hp = 1.0 # =========================== # power factor modes used to generate reactive power time series for loads and generators -mv_gen = inductive -mv_load = inductive -mv_storage = inductive -mv_cp = inductive -mv_hp = inductive -lv_gen = inductive -lv_load = inductive -lv_storage = inductive -lv_cp = inductive -lv_hp = inductive +mv_generator = inductive +mv_conventional_load = inductive +mv_storage_unit = inductive +mv_charging_point = inductive +mv_heat_pump = inductive +lv_generator = inductive +lv_conventional_load = inductive +lv_storage_unit = inductive +lv_charging_point = inductive +lv_heat_pump = inductive [demandlib] @@ -129,6 +129,8 @@ week_day = 0.8 week_night = 0.6 weekend_day = 0.6 weekend_night = 0.6 +holiday_day = 0.6 +holiday_night = 0.6 # tuple specifying the beginning/end of a workday (e.g. 18:00) day_start = 6:00 day_end = 22:00 diff --git a/edisgo/flex_opt/q_control.py b/edisgo/flex_opt/q_control.py index a6e98578e..07183a7d5 100644 --- a/edisgo/flex_opt/q_control.py +++ b/edisgo/flex_opt/q_control.py @@ -92,22 +92,6 @@ def fixed_cosphi(active_power, q_sign, power_factor): return active_power * q_sign * np.tan(np.arccos(power_factor)) -def _get_component_dict(): - """ - Helper function to translate from component type term used in function to the one - used in the config files. - - """ - comp_dict = { - "generators": "gen", - "storage_units": "storage", - "conventional_loads": "load", - "charging_points": "cp", - "heat_pumps": "hp", - } - return comp_dict - - def _fixed_cosphi_default_power_factor(comp_df, component_type, configs): """ Gets fixed cosphi default reactive power factor for each given component. @@ -116,15 +100,15 @@ def _fixed_cosphi_default_power_factor(comp_df, component_type, configs): ----------- comp_df : :pandas:`pandas.DataFrame` Dataframe with component names (in the index) of all components - reactive power factor needs to be set. Only required column is + reactive power factor needs to be set for. Only required column is column 'voltage_level', giving the voltage level the component is in (the voltage level can be set using the function :func:`~.tools.tools.assign_voltage_level_to_component`). All components must have the same `component_type`. component_type : str The component type determines the reactive power factor and mode used. - Possible options are 'generators', 'storage_units', 'conventional_loads', - 'charging_points', and 'heat_pumps'. + Possible options are 'generator', 'storage_unit', 'conventional_load', + 'charging_point', and 'heat_pump'. configs : :class:`~.tools.config.Config` eDisGo configuration data. @@ -136,22 +120,28 @@ def _fixed_cosphi_default_power_factor(comp_df, component_type, configs): """ reactive_power_factor = configs["reactive_power_factor"] - comp_dict = _get_component_dict() - - if component_type in comp_dict.keys(): - comp = comp_dict[component_type] + allowed_types = [ + "generator", + "storage_unit", + "conventional_load", + "charging_point", + "heat_pump", + ] + if component_type in allowed_types: # write series with power factor for each component power_factor = pd.Series(index=comp_df.index, dtype=float) for voltage_level in comp_df.voltage_level.unique(): cols = comp_df.index[comp_df.voltage_level == voltage_level] if len(cols) > 0: - power_factor[cols] = reactive_power_factor[f"{voltage_level}_{comp}"] + power_factor[cols] = reactive_power_factor[ + f"{voltage_level}_{component_type}" + ] return power_factor else: raise ValueError( "Given 'component_type' is not valid. Valid options are " - "'generators','storage_units', 'conventional_loads', 'charging_points', " - "and 'heat_pumps'." + "'generator', 'storage_unit', 'conventional_load', 'charging_point', " + "and 'heat_pump'." ) @@ -170,8 +160,8 @@ def _fixed_cosphi_default_reactive_power_sign(comp_df, component_type, configs): All components must have the same `component_type`. component_type : str The component type determines the reactive power factor and mode used. - Possible options are 'generators', 'storage_units', 'conventional_loads', - 'charging_points', and 'heat_pumps'. + Possible options are 'generator', 'storage_unit', 'conventional_load', + 'charging_point', and 'heat_pump'. configs : :class:`~.tools.config.Config` eDisGo configuration data. @@ -183,17 +173,15 @@ def _fixed_cosphi_default_reactive_power_sign(comp_df, component_type, configs): """ reactive_power_mode = configs["reactive_power_mode"] - comp_dict = _get_component_dict() q_sign_dict = { - "generators": get_q_sign_generator, - "storage_units": get_q_sign_generator, - "conventional_loads": get_q_sign_load, - "charging_points": get_q_sign_load, - "heat_pumps": get_q_sign_load, + "generator": get_q_sign_generator, + "storage_unit": get_q_sign_generator, + "conventional_load": get_q_sign_load, + "charging_point": get_q_sign_load, + "heat_pump": get_q_sign_load, } - if component_type in comp_dict.keys(): - comp = comp_dict[component_type] + if component_type in q_sign_dict.keys(): get_q_sign = q_sign_dict[component_type] # write series with power factor for each component q_sign = pd.Series(index=comp_df.index, dtype=float) @@ -201,12 +189,12 @@ def _fixed_cosphi_default_reactive_power_sign(comp_df, component_type, configs): cols = comp_df.index[comp_df.voltage_level == voltage_level] if len(cols) > 0: q_sign[cols] = get_q_sign( - reactive_power_mode[f"{voltage_level}_{comp}"] + reactive_power_mode[f"{voltage_level}_{component_type}"] ) return q_sign else: raise ValueError( "Given 'component_type' is not valid. Valid options are " - "'generators','storage_units', 'conventional_loads', 'charging_points', " - "and 'heat_pumps'." + "'generator', 'storage_unit', 'conventional_load', 'charging_point', " + "and 'heat_pump'." ) diff --git a/edisgo/io/powermodels_io.py b/edisgo/io/powermodels_io.py index b0fb22781..541e01ffc 100644 --- a/edisgo/io/powermodels_io.py +++ b/edisgo/io/powermodels_io.py @@ -667,7 +667,7 @@ def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): gen.bus[gen_i], flexible_storage_units=flexible_storage_units, ) - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "gen") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "generator") q = [ sign * np.tan(np.arccos(pf)) * gen.p_nom[gen_i], sign * np.tan(np.arccos(pf)) * gen.p_nom_min[gen_i], @@ -704,7 +704,7 @@ def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): psa_net.storage_units.bus.loc[inflexible_storage_units[stor_i]], flexible_storage_units=flexible_storage_units, ) - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") p_g = max( [ psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]][0], @@ -837,7 +837,7 @@ def _build_branch(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): flexible_storage_units=flexible_storage_units, ) # retrieve power factor from config - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") pm["branch"][str(stor_i + len(branches.index) + 1)] = { "name": "bss_branch_" + str(stor_i + 1), @@ -919,22 +919,22 @@ def _build_load( edisgo_obj.topology.loads_df.loc[loads_df.index[load_i]].type == "conventional_load" ): - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "load") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "conventional_load") elif ( edisgo_obj.topology.loads_df.loc[loads_df.index[load_i]].type == "heat_pump" ): - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "hp") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "heat_pump") elif ( edisgo_obj.topology.loads_df.loc[loads_df.index[load_i]].type == "charging_point" ): - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "cp") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "charging_point") else: logger.warning( "No type specified for load {}. Power factor and sign will" "be set for conventional load.".format(loads_df.index[load_i]) ) - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "load") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "conventional_load") p_d = psa_net.loads_t.p_set[loads_df.index[load_i]] q_d = psa_net.loads_t.q_set[loads_df.index[load_i]] pm["load"][str(load_i + 1)] = { @@ -955,7 +955,7 @@ def _build_load( psa_net.storage_units.bus.loc[inflexible_storage_units[stor_i]], flexible_storage_units=flexible_storage_units, ) - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") p_d = -min( [ psa_net.storage_units_t.p_set[inflexible_storage_units[stor_i]][0], @@ -1036,7 +1036,7 @@ def _build_battery_storage( flexible_storage_units=flexible_storage_units, ) # retrieve power factor from config - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "storage_unit") e_max = ( psa_net.storage_units.p_nom.loc[flexible_storage_units[stor_i]] * psa_net.storage_units.max_hours.loc[flexible_storage_units[stor_i]] @@ -1151,7 +1151,7 @@ def _build_electromobility(edisgo_obj, psa_net, pm, s_base, flexible_cps): eta = edisgo_obj.electromobility.simbev_config_df.eta_cp.values[0] except IndexError: eta = 0.9 - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "cp") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "charging_point") q = ( sign * np.tan(np.arccos(pf)) @@ -1218,7 +1218,7 @@ def _build_heatpump(psa_net, pm, edisgo_obj, s_base, flexible_hps): for hp_i in np.arange(len(heat_df.index)): idx_bus = _mapping(psa_net, edisgo_obj, heat_df.bus[hp_i]) # retrieve power factor and sign from config - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "hp") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "heat_pump") q = sign * np.tan(np.arccos(pf)) * heat_df.p_set[hp_i] p_d = heat_df2[heat_df.index[hp_i]] pm["heatpumps"][str(hp_i + 1)] = { @@ -1446,7 +1446,7 @@ def _build_dsm(edisgo_obj, psa_net, pm, s_base, flexible_loads): for dsm_i in np.arange(len(dsm_df.index)): idx_bus = _mapping(psa_net, edisgo_obj, dsm_df.bus[dsm_i]) # retrieve power factor and sign from config - pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "load") + pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "conventional_load") p_max = edisgo_obj.dsm.p_max[dsm_df.index[dsm_i]] p_min = edisgo_obj.dsm.p_min[dsm_df.index[dsm_i]] e_min = edisgo_obj.dsm.e_min[dsm_df.index[dsm_i]] @@ -2053,7 +2053,8 @@ def _get_pf(edisgo_obj, pm, idx_bus, kind): idx_bus : int Bus index from PowerModels bus dictionary. kind : str - Must be one of ["gen", "load", "storage", "hp", "cp"]. + Must be one of ["generator", "conventional_load", "storage_unit", "heat_pump", + "charging_point"]. Returns ------- @@ -2061,18 +2062,14 @@ def _get_pf(edisgo_obj, pm, idx_bus, kind): """ grid_level = pm["bus"][str(idx_bus)]["grid_level"] - pf = edisgo_obj.config._data["reactive_power_factor"][ - "{}_{}".format(grid_level, kind) - ] - sign = edisgo_obj.config._data["reactive_power_mode"][ - "{}_{}".format(grid_level, kind) - ] - if kind in ["gen", "storage"]: + pf = edisgo_obj.config["reactive_power_factor"]["{}_{}".format(grid_level, kind)] + sign = edisgo_obj.config["reactive_power_mode"]["{}_{}".format(grid_level, kind)] + if kind in ["generator", "storage_unit"]: if sign == "inductive": sign = -1 else: sign = 1 - elif kind in ["load", "hp", "cp"]: + elif kind in ["conventional_load", "heat_pump", "charging_point"]: if sign == "inductive": sign = 1 else: diff --git a/edisgo/io/timeseries_import.py b/edisgo/io/timeseries_import.py index 5d154b965..6c66f33af 100644 --- a/edisgo/io/timeseries_import.py +++ b/edisgo/io/timeseries_import.py @@ -297,6 +297,10 @@ def load_time_series_demandlib(edisgo_obj, timeindex=None): "day": edisgo_obj.config["demandlib"]["weekend_day"], "night": edisgo_obj.config["demandlib"]["weekend_night"], }, + "holiday": { + "day": edisgo_obj.config["demandlib"]["holiday_day"], + "night": edisgo_obj.config["demandlib"]["holiday_night"], + }, }, ) diff --git a/edisgo/network/grids.py b/edisgo/network/grids.py index 7f466b3dc..20d50ed74 100644 --- a/edisgo/network/grids.py +++ b/edisgo/network/grids.py @@ -90,20 +90,19 @@ def graph(self): @property def geopandas(self): """ - Returns components as :geopandas:`GeoDataFrame`\\ s + Returns components as :geopandas:`GeoDataFrame`\\ s. Returns container with :geopandas:`GeoDataFrame`\\ s containing all georeferenced components within the grid. Returns ------- - :class:`~.tools.geopandas_helper.GeoPandasGridContainer` or \ - list(:class:`~.tools.geopandas_helper.GeoPandasGridContainer`) + :class:`~.tools.geopandas_helper.GeoPandasGridContainer` Data container with GeoDataFrames containing all georeferenced components - within the grid(s). + within the grid. """ - return to_geopandas(self) + return to_geopandas(self, srid=self.edisgo_obj.topology.grid_district["srid"]) @property def station(self): @@ -650,10 +649,3 @@ def draw( else: plt.savefig(filename, dpi=150, bbox_inches="tight", pad_inches=0.1) plt.close() - - @property - def geopandas(self): - """ - TODO: Remove this as soon as LVGrids are georeferenced - """ - raise NotImplementedError("LV Grids are not georeferenced yet.") diff --git a/edisgo/network/overlying_grid.py b/edisgo/network/overlying_grid.py index 77c9ccadd..241768a20 100644 --- a/edisgo/network/overlying_grid.py +++ b/edisgo/network/overlying_grid.py @@ -385,6 +385,16 @@ def distribute_overlying_grid_requirements(edisgo_obj): scaling_df_min = ( edisgo_obj.dsm.p_min.transpose() / edisgo_obj.dsm.p_min.sum(axis=1) ) + # in case p_max/p_min of all DSM loads is zero in an hour but there is + # positive/negative DSM from the overlying grid, this is not correctly + # distributed and may lead to large errors in the time series with the + # distributed DSM + # in the following this is corrected by assuming an equal distribution + # during those hours + equal_dist_factor = 1 / len(dsm_loads) + scaling_df_max.fillna(equal_dist_factor, inplace=True) + scaling_df_min.fillna(equal_dist_factor, inplace=True) + edisgo_copy.timeseries._loads_active_power.loc[:, dsm_loads] = ( edisgo_obj.timeseries._loads_active_power.loc[:, dsm_loads] + ( diff --git a/edisgo/network/timeseries.py b/edisgo/network/timeseries.py index 15337f06b..6cf4a7b47 100644 --- a/edisgo/network/timeseries.py +++ b/edisgo/network/timeseries.py @@ -821,10 +821,10 @@ def _worst_case_generators(self, cases, df, configs): # reactive power # get worst case configurations for each generator power_factor = q_control._fixed_cosphi_default_power_factor( - df, "generators", configs + df, "generator", configs ) q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, "generators", configs + df, "generator", configs ) # write reactive power configuration to TimeSeriesRaw self.time_series_raw.q_control.drop(df.index, errors="ignore", inplace=True) @@ -899,10 +899,10 @@ def _worst_case_conventional_load(self, cases, df, configs): # reactive power # get worst case configurations for each load power_factor = q_control._fixed_cosphi_default_power_factor( - df, "conventional_loads", configs + df, "conventional_load", configs ) q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, "conventional_loads", configs + df, "conventional_load", configs ) # write reactive power configuration to TimeSeriesRaw self.time_series_raw.q_control.drop(df.index, errors="ignore", inplace=True) @@ -999,10 +999,10 @@ def _worst_case_charging_points(self, cases, df, configs): # reactive power # get worst case configurations for each charging point power_factor = q_control._fixed_cosphi_default_power_factor( - df, "charging_points", configs + df, "charging_point", configs ) q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, "charging_points", configs + df, "charging_point", configs ) # write reactive power configuration to TimeSeriesRaw self.time_series_raw.q_control.drop(df.index, errors="ignore", inplace=True) @@ -1077,10 +1077,10 @@ def _worst_case_heat_pumps(self, cases, df, configs): # reactive power # get worst case configurations for each heat pump power_factor = q_control._fixed_cosphi_default_power_factor( - df, "heat_pumps", configs + df, "heat_pump", configs ) q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, "heat_pumps", configs + df, "heat_pump", configs ) # write reactive power configuration to TimeSeriesRaw self.time_series_raw.q_control.drop(df.index, errors="ignore", inplace=True) @@ -1153,10 +1153,10 @@ def _worst_case_storage_units(self, cases, df, configs): # reactive power # get worst case configurations for each load power_factor = q_control._fixed_cosphi_default_power_factor( - df, "storage_units", configs + df, "storage_unit", configs ) q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, "storage_units", configs + df, "storage_unit", configs ) # write reactive power configuration to TimeSeriesRaw self.time_series_raw.q_control.drop(df.index, errors="ignore", inplace=True) @@ -1606,7 +1606,7 @@ def _get_q_sign_and_power_factor_per_component( q_sign, q_control._fixed_cosphi_default_reactive_power_sign( df[df["type"] == load_type], - f"{load_type}s", + load_type, edisgo_object.config, ), ] @@ -1616,17 +1616,17 @@ def _get_q_sign_and_power_factor_per_component( power_factor, q_control._fixed_cosphi_default_power_factor( df[df["type"] == load_type], - f"{load_type}s", + load_type, edisgo_object.config, ), ] ) else: q_sign = q_control._fixed_cosphi_default_reactive_power_sign( - df, type, edisgo_object.config + df, type[:-1], edisgo_object.config ) power_factor = q_control._fixed_cosphi_default_power_factor( - df, type, edisgo_object.config + df, type[:-1], edisgo_object.config ) elif isinstance(parametrisation, pd.DataFrame): # check if all given components exist in network and only use existing @@ -1659,7 +1659,7 @@ def _get_q_sign_and_power_factor_per_component( q_sign, default_func( df[df["type"] == load_type], - f"{load_type}s", + load_type, edisgo_object.config, ), ] @@ -1668,7 +1668,9 @@ def _get_q_sign_and_power_factor_per_component( q_sign = pd.concat( [ q_sign, - default_func(df, type, edisgo_object.config), + default_func( + df, type[:-1], edisgo_object.config + ), ] ) else: @@ -1692,7 +1694,7 @@ def _get_q_sign_and_power_factor_per_component( power_factor, default_func( df[df["type"] == load_type], - f"{load_type}s", + load_type, edisgo_object.config, ), ] @@ -1701,7 +1703,9 @@ def _get_q_sign_and_power_factor_per_component( power_factor = pd.concat( [ power_factor, - default_func(df, type, edisgo_object.config), + default_func( + df, type[:-1], edisgo_object.config + ), ] ) else: diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index 185abbd4e..a71539e62 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -5,6 +5,7 @@ import random import warnings +from typing import TYPE_CHECKING from zipfile import ZipFile import networkx as nx @@ -15,7 +16,7 @@ from edisgo.network.components import Switch from edisgo.network.grids import LVGrid, MVGrid -from edisgo.tools import geo, networkx_helper +from edisgo.tools import geo, geopandas_helper, networkx_helper from edisgo.tools.tools import ( calculate_apparent_power, calculate_line_reactance, @@ -30,6 +31,8 @@ from shapely.ops import transform from shapely.wkt import loads as wkt_loads +if TYPE_CHECKING: + from edisgo.tools.geopandas_helper import GeoPandasGridContainer logger = logging.getLogger(__name__) @@ -1931,7 +1934,13 @@ def connect_to_mv( # avoid very short lines by limiting line length to at least 1m line_length = max(line_length, 0.001) - line_type, num_parallel = select_cable(edisgo_object, "mv", power) + line_type, num_parallel = select_cable( + edisgo_obj=edisgo_object, + level="mv", + apparent_power=power, + length=line_length, + component_type=comp_type, + ) line_name = self.add_line( bus0=self.mv_grid.station.index[0], @@ -1978,13 +1987,12 @@ def connect_to_mv( for dist_min_obj in conn_objects_min_stack: # do not allow connection to virtual busses if "virtual" not in dist_min_obj["repr"]: - line_type, num_parallel = select_cable(edisgo_object, "mv", power) target_obj_result = self._connect_mv_bus_to_target_object( edisgo_object=edisgo_object, bus=self.buses_df.loc[bus, :], target_obj=dist_min_obj, - line_type=line_type.name, - number_parallel_lines=num_parallel, + comp_type=comp_type, + power=power, ) if target_obj_result is not None: @@ -2451,7 +2459,12 @@ def connect_to_lv_based_on_geolocation( return comp_name def _connect_mv_bus_to_target_object( - self, edisgo_object, bus, target_obj, line_type, number_parallel_lines + self, + edisgo_object, + bus, + target_obj, + comp_type, + power, ): """ Connects given MV bus to given target object (MV line or bus). @@ -2480,11 +2493,12 @@ def _connect_mv_bus_to_target_object( * shp : :shapely:`Shapely Point object` or \ :shapely:`Shapely Line object` Geometry of line or bus to connect to. - - line_type : str - Line type to use to connect new component with. - number_parallel_lines : int - Number of parallel lines to connect new component with. + comp_type : str + Type of added component. Can be 'generator', 'charging_point', 'heat_pump' + or 'storage_unit'. + Default: 'generator'. + power : float + Nominal power of the new component to be connected. Returns ------- @@ -2601,6 +2615,13 @@ def _connect_mv_bus_to_target_object( "branch_detour_factor" ], ) + line_type, num_parallel = select_cable( + edisgo_obj=edisgo_object, + level="mv", + apparent_power=power, + length=line_length, + component_type=comp_type, + ) # avoid very short lines by limiting line length to at least 1m if line_length < 0.001: line_length = 0.001 @@ -2609,8 +2630,8 @@ def _connect_mv_bus_to_target_object( bus1=bus.name, length=line_length, kind="cable", - type_info=line_type, - num_parallel=number_parallel_lines, + type_info=line_type.name, + num_parallel=num_parallel, ) # add line to equipment changes edisgo_object.results._add_line_to_equipment_changes( @@ -2627,7 +2648,7 @@ def _connect_mv_bus_to_target_object( # bus is the nearest connection point else: - # add new branch for satellite (station to station) + # add new line between new bus and closest bus line_length = geo.calc_geo_dist_vincenty( grid_topology=self, bus_source=bus.name, @@ -2636,6 +2657,13 @@ def _connect_mv_bus_to_target_object( "branch_detour_factor" ], ) + line_type, num_parallel = select_cable( + edisgo_obj=edisgo_object, + level="mv", + apparent_power=power, + length=line_length, + component_type=comp_type, + ) # avoid very short lines by limiting line length to at least 1m if line_length < 0.001: line_length = 0.001 @@ -2645,8 +2673,8 @@ def _connect_mv_bus_to_target_object( bus1=bus.name, length=line_length, kind="cable", - type_info=line_type, - num_parallel=number_parallel_lines, + type_info=line_type.name, + num_parallel=num_parallel, ) # add line to equipment changes @@ -2730,7 +2758,13 @@ def _connect_to_lv_bus( line_length = max(line_length, 0.001) # get suitable line type - line_type, num_parallel = select_cable(edisgo_object, "lv", comp_data["p"]) + line_type, num_parallel = select_cable( + edisgo_obj=edisgo_object, + level="lv", + apparent_power=comp_data["p"], + component_type=comp_type, + length=line_length, + ) line_name = self.add_line( bus0=target_bus, bus1=b, @@ -2765,7 +2799,9 @@ def to_graph(self) -> nx.Graph: self.transformers_df, ) - def to_geopandas(self, mode: str = "mv"): + def to_geopandas( + self, mode: str | None = None, lv_grid_id: int | None = None + ) -> GeoPandasGridContainer: """ Returns components as :geopandas:`GeoDataFrame`\\ s. @@ -2775,23 +2811,29 @@ def to_geopandas(self, mode: str = "mv"): Parameters ---------- mode : str - Return mode. If mode is "mv" the mv components are returned. If mode is "lv" - a generator with a container per lv grid is returned. Default: "mv" + If `mode` is None, GeoDataFrames for the MV grid and underlying LV grids is + returned. If `mode` is "mv", GeoDataFrames for only the MV grid are + returned. If `mode` is "lv", GeoDataFrames for the LV grid specified through + `lv_grid_id` are returned. + Default: None. + lv_grid_id : int + Only needs to be provided in case `mode` is "lv". In that case `lv_grid_id` + gives the LV grid ID as integer of the LV grid for which to return the + geodataframes. Returns ------- - :class:`~.tools.geopandas_helper.GeoPandasGridContainer` or \ - list(:class:`~.tools.geopandas_helper.GeoPandasGridContainer`) + :class:`~.tools.geopandas_helper.GeoPandasGridContainer` Data container with GeoDataFrames containing all georeferenced components - within the grid(s). + within the grid. """ - if mode == "mv": + if mode is None: + return geopandas_helper.to_geopandas(self, srid=self.grid_district["srid"]) + elif mode == "mv": return self.mv_grid.geopandas elif mode == "lv": - raise NotImplementedError("LV Grids are not georeferenced yet.") - # for lv_grid in self.mv_grid.lv_grids: - # yield lv_grid.geopandas + return self.get_lv_grid(name=lv_grid_id).geopandas else: raise ValueError(f"{mode} is not valid. See docstring for more info.") @@ -3101,6 +3143,9 @@ def check_integrity(self): f"optimisation." ) + # check for meshed grid + self.find_meshes() + def assign_feeders(self, mode: str = "grid_feeder"): """ Assigns MV or LV feeder to each bus and line, depending on the `mode`. @@ -3169,3 +3214,31 @@ def aggregate_lv_grid_at_station(self, lv_grid_id: int | str) -> None: def __repr__(self): return f"Network topology {self.id}" + + def find_meshes(edisgo_obj) -> list[list[int]] | None: + """ + Find all meshes in the grid. + + Parameters + ---------- + edisgo_obj : EDisGo + EDisGo object. + + Returns + ------- + Optional[List[List[int]]] + List of all meshes in the grid. + Each mesh is represented as a list of node indices. + If no meshes are found, None is returned. + """ + meshes = nx.cycle_basis(edisgo_obj.to_graph()) + if meshes: + logger.warning( + "Grid contains mesh(es). Be aware, that the grid expansion methodology " + "is currently not able to handle meshes. Further, the optimisation of " + "flexibility dispatch is not exact in case of meshed grids, but can " + "still be used." + ) + return meshes + else: + return None diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/data.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/data.jl index 1063cc1f4..f180f831a 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/data.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/data.jl @@ -1,7 +1,4 @@ function set_ac_bf_start_values!(network::Dict{String,<:Any}) - for (i,bus) in network["bus"] - bus["w_start"] = bus["w"] - end for (i,gen) in network["gen_nd"] gen["pgc_start"] = gen["pgc"] diff --git a/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl b/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl index e1db2d1bb..1245fe7bf 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl @@ -118,6 +118,23 @@ function constraint_max_line_loading(pm::AbstractSOCBFModelEdisgo, n::Int) end +function constraint_max_line_loading(pm::AbstractNCBFModelEdisgo, n::Int) + p = PowerModels.var(pm, n, :p) + q = PowerModels.var(pm, n, :q) + ll = PowerModels.var(pm, 1, :ll) + s_nom = Dict(i => get(branch, "rate_a", 1.0) for (i,branch) in PowerModels.ref(pm, n, :branch)) + + for (i,branch) in PowerModels.ref(pm, n, :branch) + f_bus = branch["f_bus"] + t_bus = branch["t_bus"] + f_idx = (i, f_bus, t_bus) + if !(branch["storage"]) + JuMP.@constraint(pm.model, (p[f_idx]^2 + q[f_idx]^2)/s_nom[i]^2 <= ll[f_idx]) + end + end +end + + function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens, bus_gens_nd, bus_gens_slack, bus_loads, bus_arcs_to, bus_arcs_from, bus_lines_to, bus_storage, bus_pg, bus_qg, bus_pg_nd, bus_qg_nd, bus_pd, bus_qd, branch_r, branch_x, bus_dsm, bus_hps, bus_cps, bus_storage_pf, bus_dsm_pf, bus_hps_pf, bus_cps_pf, bus_gen_nd_pf, bus_gen_d_pf, bus_loads_pf, branch_strg_pf) pt = get(PowerModels.var(pm, n), :p, Dict()); PowerModels._check_var_keys(pt, bus_arcs_to, "active power", "branch") qt = get(PowerModels.var(pm, n), :q, Dict()); PowerModels._check_var_keys(qt, bus_arcs_to, "reactive power", "branch") diff --git a/edisgo/opf/powermodels_opf.py b/edisgo/opf/powermodels_opf.py index ebd9c853a..c26475141 100644 --- a/edisgo/opf/powermodels_opf.py +++ b/edisgo/opf/powermodels_opf.py @@ -8,6 +8,7 @@ from edisgo.flex_opt import exceptions from edisgo.io.powermodels_io import from_powermodels +from edisgo.network.topology import Topology logger = logging.getLogger(__name__) @@ -105,6 +106,7 @@ def pm_optimize( Default: True. """ + Topology.find_meshes(edisgo_obj) opf_dir = os.path.dirname(os.path.abspath(__file__)) solution_dir = os.path.join(opf_dir, "opf_solutions") pm, hv_flex_dict = edisgo_obj.to_powermodels( diff --git a/edisgo/tools/config.py b/edisgo/tools/config.py index 54fc08a33..7494943a3 100644 --- a/edisgo/tools/config.py +++ b/edisgo/tools/config.py @@ -116,7 +116,7 @@ class Config: Get reactive power factor for generators in the MV network - >>> config['reactive_power_factor']['mv_gen'] + >>> config['reactive_power_factor']['mv_generator'] """ diff --git a/edisgo/tools/geopandas_helper.py b/edisgo/tools/geopandas_helper.py index 6c48a62e6..14066d560 100644 --- a/edisgo/tools/geopandas_helper.py +++ b/edisgo/tools/geopandas_helper.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from edisgo.network.grids import Grid + from edisgo.network.topology import Topology COMPONENTS: list[str] = [ "generators_df", @@ -162,14 +163,17 @@ def plot(self): raise NotImplementedError -def to_geopandas(grid_obj: Grid): +def to_geopandas(grid_obj: Grid | Topology, srid: int) -> GeoPandasGridContainer: """ - Translates all DataFrames with geolocations within a Grid class to GeoDataFrames. + Translates all DataFrames with geolocations within a grid topology to GeoDataFrames. Parameters ---------- - grid_obj : :class:`~.network.grids.Grid` - Grid object to transform. + grid_obj : :class:`~.network.grids.Grid` or :class:`~.network.topology.Topology` + Grid or Topology object to transform. + srid : int + SRID (spatial reference ID) of x and y coordinates of buses. Usually given in + Topology.grid_district["srid"]. Returns ------- @@ -178,9 +182,6 @@ def to_geopandas(grid_obj: Grid): their geolocation. """ - # get srid id - srid = grid_obj._edisgo_obj.topology.grid_district["srid"] - # convert buses_df buses_df = grid_obj.buses_df buses_df = buses_df.assign( @@ -204,25 +205,27 @@ def to_geopandas(grid_obj: Grid): crs=f"EPSG:{srid}", ) if components_dict[component.replace("_df", "_gdf")].empty: - components_dict[component.replace("_df", "_gdf")].index = components_dict[ - component.replace("_df", "_gdf") - ].index.astype(object) + components_dict[component.replace("_df", "_gdf")].index = attr.index # convert lines_df lines_df = grid_obj.lines_df - geom_0 = lines_df.merge( - buses_gdf[["geometry"]], left_on="bus0", right_index=True - ).geometry - geom_1 = lines_df.merge( - buses_gdf[["geometry"]], left_on="bus1", right_index=True - ).geometry - - geometry = [ - LineString([point_0, point_1]) for point_0, point_1 in list(zip(geom_0, geom_1)) - ] - - lines_gdf = gpd.GeoDataFrame(lines_df.assign(geometry=geometry), crs=f"EPSG:{srid}") + lines_gdf = lines_df.merge( + buses_gdf[["geometry", "v_nom"]].rename(columns={"geometry": "geom_0"}), + left_on="bus0", + right_index=True, + ) + lines_gdf = lines_gdf.merge( + buses_gdf[["geometry"]].rename(columns={"geometry": "geom_1"}), + left_on="bus1", + right_index=True, + ) + lines_gdf["geometry"] = lines_gdf.apply( + lambda _: LineString([_["geom_0"], _["geom_1"]]), axis=1 + ) + lines_gdf = gpd.GeoDataFrame( + lines_gdf.drop(columns=["geom_0", "geom_1"]), crs=f"EPSG:{srid}" + ) return GeoPandasGridContainer( crs=f"EPSG:{srid}", diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index 95491f4e8..c5df32859 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -14,7 +14,7 @@ from sqlalchemy.engine.base import Engine -from edisgo.flex_opt import exceptions +from edisgo.flex_opt import exceptions, q_control from edisgo.io.db import session_scope_egon_data, sql_grid_geom, sql_intersects from edisgo.tools import session_scope @@ -209,13 +209,157 @@ def drop_duplicated_columns(df: pd.DataFrame, keep: str = "last") -> pd.DataFram return df.loc[:, ~df.columns.duplicated(keep=keep)] -def select_cable(edisgo_obj, level, apparent_power): +def calculate_voltage_diff_pu_per_line( + s_max: float | np.ndarray, + r_total: float | np.ndarray, + x_total: float | np.ndarray, + v_nom: float | np.ndarray, + q_sign: int, + power_factor: float, +) -> float | np.ndarray: + """ + Calculate the voltage difference across a line in p.u.. + + Parameters + ---------- + s_max : float or array-like + Apparent power the cable must carry in MVA. + r_total : float or array-like + Total resistance of the line in Ohms. + x_total : float or array-like + Total reactance of the line in Ohms. + v_nom : float or array-like + Nominal voltage of the line in kV. + q_sign : int + `q_sign` defines whether the reactive power is positive or + negative and must either be -1 or +1. In case of generators and storage units, + inductive reactive power is negative. In case of loads, inductive reactive + power is positive. + power_factor : :pandas:`pandas.Series` or float + Ratio of real to apparent power. + + Returns + ------- + float or array-like + Voltage difference in p.u.. If positive, the voltage difference behaves like + expected, it rises for generators and drops for loads. If negative, + the voltage difference behaves counterintuitively, it drops for generators + and rises for loads. + + """ + sin_phi = np.sqrt(1 - power_factor**2) + # Calculate the voltage difference using the formula from VDE-AR-N 4105 + voltage_diff = (s_max / (v_nom**2)) * ( + r_total * power_factor + q_sign * x_total * sin_phi + ) + return voltage_diff # in pu + + +def calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj: EDisGo, + cable_names: str | np.ndarray, + length: float, + num_parallel: int, + v_nom: float | np.ndarray, + s_max: float | np.ndarray, + component_type: str, +) -> float | np.ndarray: """ - Selects suitable cable type and quantity using given apparent power. + Calculate the voltage difference across a line in p.u. depending on line type + and component type. - Cable is selected to be able to carry the given `apparent_power`, no load - factor is considered. Overhead lines are not considered in choosing a - suitable cable. + This function serves as a helper function for function + :py:func:`calculate_voltage_diff_pu_per_line`, as it automatically obtains the + equipment data per line type from the provided equipment data and default reactive + power data per component type from the configuration files. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + cable_names : str or array-like + Resistance per kilometer of the cable in ohm/km. + length : float + Length of the cable in km. + num_parallel : int + Number of parallel cables. + v_nom : int + Nominal voltage of the cable(s) in kV. + s_max : float + Apparent power the cable must carry in MVA. + component_type : str, optional + Type of the component to be connected, used to obtain the default reactive power + mode and power factor from the configuration file. If this is given, + `reactive_power_mode` and `power_factor` are not considered. + Possible options are "generator", "conventional_load", "charging_point", + "heat_pump" and "storage_unit". + + Returns + ------- + float or array-like + Voltage difference in p.u.. If positive, the voltage difference behaves like + expected, it rises for generators and drops for loads. If negative, + the voltage difference behaves counterintuitively, it drops for generators + and rises for loads. + + """ + # calculate total resistance and reactance for the given length and + # number of parallel cables for given cable types + config_type = "mv_cables" if v_nom > 1.0 else "lv_cables" + cable_data = edisgo_obj.topology.equipment_data[config_type] + r_total = calculate_line_resistance( + cable_data.loc[cable_names, "R_per_km"], length, num_parallel + ) + x_total = calculate_line_reactance( + cable_data.loc[cable_names, "L_per_km"], length, num_parallel + ) + + # get sign of reactive power based on component type + config_type = f"mv_{component_type}" if v_nom > 1.0 else f"lv_{component_type}" + if component_type in ["generator", "storage_unit"]: + q_sign = q_control.get_q_sign_generator( + edisgo_obj.config["reactive_power_mode"][config_type] + ) + elif component_type in ["conventional_load", "heat_pump", "charging_point"]: + q_sign = q_control.get_q_sign_load( + edisgo_obj.config["reactive_power_mode"][config_type] + ) + else: + raise ValueError( + "Specified component type is not valid. " + "Must either be 'generator', 'conventional_load', 'charging_point', " + "'heat_pump' or 'storage_unit'." + ) + + # get power factor based on component type + power_factor = edisgo_obj.config["reactive_power_factor"][config_type] + + # Calculate the voltage drop or increase + return calculate_voltage_diff_pu_per_line( + s_max, + r_total, + x_total, + v_nom, + q_sign, + power_factor, + ) + + +def select_cable( + edisgo_obj: EDisGo, + level: str, + apparent_power: float, + component_type: str | None = None, + length: float = 0.0, + max_voltage_diff: float | None = None, + max_cables: int = 7, +) -> tuple[pd.Series, int]: + """ + Selects suitable cable type and quantity based on apparent power and + voltage deviation. + + The cable is selected to carry the given `apparent_power` and to ensure + acceptable voltage deviation over the cable. + Overhead lines are not considered in choosing a suitable cable. Parameters ---------- @@ -225,49 +369,96 @@ def select_cable(edisgo_obj, level, apparent_power): 'lv'. apparent_power : float Apparent power the cable must carry in MVA. + component_type : str + Type of the component to be connected. Possible options are "generator", + "conventional_load", "charging_point", "heat_pump" or "storage_unit". + Only needed in case a cable length is given and thus the voltage difference over + the cable can be taken into account for selecting a suitable cable. In that case + it is used to obtain the default power factor and reactive power mode from the + configuration files in sections `reactive_power_factor` and + `reactive_power_mode`. + Default: None. + length : float + Length of the cable in km. Default: 0. + max_voltage_diff : float + Maximum allowed voltage difference in p.u.. + If None, it defaults to the value specified in the configuration file + under the `grid_connection` section for the respective voltage level + (lv_max_voltage_deviation for LV and mv_max_voltage_deviation for MV). + Default: None. + max_cables : int + Maximum number of cables to consider. Default: 7. Returns ------- - :pandas:`pandas.Series` - Series with attributes of selected cable as in equipment data and - cable type as series name. - int - Number of necessary parallel cables. + tuple[:pandas:`pandas.Series`, int] + A tuple containing information on the selected cable type and the quantity + needed. """ - - cable_count = 1 - if level == "mv": cable_data = edisgo_obj.topology.equipment_data["mv_cables"] available_cables = cable_data[ cable_data["U_n"] == edisgo_obj.topology.mv_grid.nominal_voltage ] + if not max_voltage_diff: + max_voltage_diff = edisgo_obj.config["grid_connection"][ + "mv_max_voltage_deviation" + ] elif level == "lv": available_cables = edisgo_obj.topology.equipment_data["lv_cables"] + if not max_voltage_diff: + max_voltage_diff = edisgo_obj.config["grid_connection"][ + "lv_max_voltage_deviation" + ] else: raise ValueError( "Specified voltage level is not valid. Must either be 'mv' or 'lv'." ) + cable_count = 1 suitable_cables = available_cables[ calculate_apparent_power( available_cables["U_n"], available_cables["I_max_th"], cable_count ) > apparent_power ] + if length != 0: + suitable_cables = suitable_cables[ + calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=edisgo_obj, + cable_names=suitable_cables.index, + length=length, + num_parallel=cable_count, + v_nom=available_cables["U_n"].values[0], + s_max=apparent_power, + component_type=component_type, + ) + < max_voltage_diff + ] # increase cable count until appropriate cable type is found - while suitable_cables.empty and cable_count < 7: + while suitable_cables.empty and cable_count < max_cables: # parameter cable_count += 1 suitable_cables = available_cables[ calculate_apparent_power( - available_cables["U_n"], - available_cables["I_max_th"], - cable_count, + available_cables["U_n"], available_cables["I_max_th"], cable_count ) > apparent_power ] + if length != 0: + suitable_cables = suitable_cables[ + calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=edisgo_obj, + cable_names=available_cables.index, + length=length, + num_parallel=cable_count, + v_nom=available_cables["U_n"].values[0], + s_max=apparent_power, + component_type=component_type, + ) + < max_voltage_diff + ] if suitable_cables.empty: raise exceptions.MaximumIterationError( "Could not find a suitable cable for apparent power of " diff --git a/rtd_requirements.txt b/rtd_requirements.txt index 3900ea862..3344a499e 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -1,5 +1,5 @@ dash < 2.9.0 -demandlib < 0.2.0 +demandlib egoio >= 0.4.7 geopy >= 2.0.0 jupyter_dash diff --git a/setup.py b/setup.py index f04a4b97c..082fbe8b3 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def read(fname): requirements = [ "contextily", "dash < 2.9.0", - "demandlib < 0.2.0", + "demandlib", "descartes", "egoio >= 0.4.7", "geoalchemy2 < 0.7.0", diff --git a/tests/flex_opt/test_q_control.py b/tests/flex_opt/test_q_control.py index 9595ec1c6..a028c1544 100644 --- a/tests/flex_opt/test_q_control.py +++ b/tests/flex_opt/test_q_control.py @@ -101,7 +101,7 @@ def test__fixed_cosphi_default_power_factor( # test for component_type="generators" pf = q_control._fixed_cosphi_default_power_factor( - comp_df=df, component_type="generators", configs=config + comp_df=df, component_type="generator", configs=config ) assert pf.shape == (3,) @@ -112,7 +112,7 @@ def test__fixed_cosphi_default_power_factor( # test for component_type="loads" pf = q_control._fixed_cosphi_default_power_factor( - comp_df=df, component_type="conventional_loads", configs=config + comp_df=df, component_type="conventional_load", configs=config ) assert pf.shape == (3,) @@ -123,7 +123,7 @@ def test__fixed_cosphi_default_power_factor( # test for component_type="charging_points" pf = q_control._fixed_cosphi_default_power_factor( - comp_df=df, component_type="charging_points", configs=config + comp_df=df, component_type="charging_point", configs=config ) assert pf.shape == (3,) @@ -134,7 +134,7 @@ def test__fixed_cosphi_default_power_factor( # test for component_type="heat_pumps" pf = q_control._fixed_cosphi_default_power_factor( - comp_df=df, component_type="heat_pumps", configs=config + comp_df=df, component_type="heat_pump", configs=config ) assert pf.shape == (3,) @@ -145,7 +145,7 @@ def test__fixed_cosphi_default_power_factor( # test for component_type="storage_units" pf = q_control._fixed_cosphi_default_power_factor( - comp_df=df, component_type="storage_units", configs=config + comp_df=df, component_type="storage_unit", configs=config ) assert pf.shape == (3,) @@ -165,7 +165,7 @@ def test__fixed_cosphi_default_reactive_power_sign( # test for component_type="generators" pf = q_control._fixed_cosphi_default_reactive_power_sign( - comp_df=df, component_type="generators", configs=config + comp_df=df, component_type="generator", configs=config ) assert pf.shape == (3,) @@ -176,7 +176,7 @@ def test__fixed_cosphi_default_reactive_power_sign( # test for component_type="conventional_loads" pf = q_control._fixed_cosphi_default_reactive_power_sign( - comp_df=df, component_type="conventional_loads", configs=config + comp_df=df, component_type="conventional_load", configs=config ) assert pf.shape == (3,) @@ -187,7 +187,7 @@ def test__fixed_cosphi_default_reactive_power_sign( # test for component_type="charging_points" pf = q_control._fixed_cosphi_default_reactive_power_sign( - comp_df=df, component_type="charging_points", configs=config + comp_df=df, component_type="charging_point", configs=config ) assert pf.shape == (3,) @@ -198,7 +198,7 @@ def test__fixed_cosphi_default_reactive_power_sign( # test for component_type="heat_pumps" pf = q_control._fixed_cosphi_default_reactive_power_sign( - comp_df=df, component_type="heat_pumps", configs=config + comp_df=df, component_type="heat_pump", configs=config ) assert pf.shape == (3,) @@ -209,7 +209,7 @@ def test__fixed_cosphi_default_reactive_power_sign( # test for component_type="storage_units" pf = q_control._fixed_cosphi_default_reactive_power_sign( - comp_df=df, component_type="storage_units", configs=config + comp_df=df, component_type="storage_unit", configs=config ) assert pf.shape == (3,) diff --git a/tests/io/test_powermodels_io.py b/tests/io/test_powermodels_io.py index 4d0d7842a..b3bfab036 100644 --- a/tests/io/test_powermodels_io.py +++ b/tests/io/test_powermodels_io.py @@ -310,7 +310,7 @@ def test__get_pf(self): # test mode None powermodels_network, hv_flex_dict = powermodels_io.to_powermodels(self.edisgo) - for component in ["gen", "storage"]: + for component in ["generator", "storage_unit"]: pf, sign = powermodels_io._get_pf( self.edisgo, powermodels_network, 1, component ) @@ -322,10 +322,10 @@ def test__get_pf(self): assert pf == 0.95 assert sign == -1 - for component in ["hp", "cp"]: + for component in ["heat_pump", "charging_point"]: for bus in [1, 29]: pf, sign = powermodels_io._get_pf( - self.edisgo, powermodels_network, 1, component + self.edisgo, powermodels_network, bus, component ) assert pf == 1 assert sign == 1 diff --git a/tests/io/test_timeseries_import.py b/tests/io/test_timeseries_import.py index 17340163b..c6abb872e 100644 --- a/tests/io/test_timeseries_import.py +++ b/tests/io/test_timeseries_import.py @@ -89,16 +89,20 @@ def test_feedin_oedb(self): def test_load_time_series_demandlib(self): edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) - timeindex = pd.date_range("1/1/2018", periods=7000, freq="H") + timeindex = pd.date_range("1/1/2018", periods=8760, freq="H") load = timeseries_import.load_time_series_demandlib(edisgo, timeindex) assert ( load.columns == ["cts", "residential", "agricultural", "industrial"] ).all() - assert len(load) == 7000 + assert len(load) == 8760 assert np.isclose(load.loc[timeindex[453], "cts"], 8.33507e-05) assert np.isclose(load.loc[timeindex[13], "residential"], 1.73151e-04) assert np.isclose(load.loc[timeindex[6328], "agricultural"], 1.01346e-04) - assert np.isclose(load.loc[timeindex[4325], "industrial"], 9.91768e-05) + assert np.isclose(load.loc[timeindex[4325], "industrial"], 9.87654320e-05) + assert np.isclose(load.sum()["cts"], 1.0) + assert np.isclose(load.sum()["residential"], 1.0) + assert np.isclose(load.sum()["agricultural"], 1.0) + assert np.isclose(load.sum()["industrial"], 1.0) @pytest.mark.local def test_cop_oedb(self): diff --git a/tests/network/test_timeseries.py b/tests/network/test_timeseries.py index 4666a836a..2e8b717b4 100644 --- a/tests/network/test_timeseries.py +++ b/tests/network/test_timeseries.py @@ -1565,9 +1565,9 @@ def test_predefined_conventional_loads_by_sector(self, caplog): index=index, columns=["cts", "residential", "agricultural", "industrial"], data=[ - [0.0000597, 0.0000782, 0.0000654, 0.0000992], - [0.0000526, 0.0000563, 0.0000611, 0.0000992], - [0.0000459, 0.0000451, 0.0000585, 0.0000992], + [0.000059711, 0.0000782190, 0.00006540, 0.00009876], + [0.000052590, 0.0000563428, 0.00006110, 0.00009876], + [0.000045927, 0.0000451043, 0.00005843, 0.00009876], ], ) @@ -1656,7 +1656,7 @@ def test_predefined_conventional_loads_by_sector(self, caplog): self.edisgo.timeseries.loads_active_power[ "Load_industrial_LVGrid_6_1" ].values, - [0.05752256] * 3, + [0.05728395] * 3, ).all() assert np.isclose( self.edisgo.timeseries.loads_active_power.loc[ diff --git a/tests/network/test_topology.py b/tests/network/test_topology.py index 0baf02f34..1a9023606 100644 --- a/tests/network/test_topology.py +++ b/tests/network/test_topology.py @@ -951,9 +951,17 @@ def setup_class(self): self.edisgo.set_time_series_worst_case_analysis() def test_to_geopandas(self): - geopandas_container = self.edisgo.topology.to_geopandas() + # further tests of to_geopandas are conducted in test_geopandas_helper.py - assert isinstance(geopandas_container, GeoPandasGridContainer) + # set up edisgo object with georeferenced LV + edisgo_geo = EDisGo( + ding0_grid=pytest.ding0_test_network_3_path, legacy_ding0_grids=False + ) + test_suits = { + "mv": {"edisgo_obj": self.edisgo, "mode": "mv", "lv_grid_id": None}, + "lv": {"edisgo_obj": edisgo_geo, "mode": "lv", "lv_grid_id": 1164120002}, + "mv+lv": {"edisgo_obj": edisgo_geo, "mode": None, "lv_grid_id": None}, + } attrs = [ "buses_gdf", @@ -964,19 +972,30 @@ def test_to_geopandas(self): "transformers_gdf", ] - for attr_str in attrs: - attr = getattr(geopandas_container, attr_str) - grid_attr = getattr( - self.edisgo.topology.mv_grid, attr_str.replace("_gdf", "_df") + for test_suit, params in test_suits.items(): + # call to_geopandas() function with different settings + geopandas_container = params["edisgo_obj"].topology.to_geopandas( + mode=params["mode"], lv_grid_id=params["lv_grid_id"] ) - assert isinstance(attr, GeoDataFrame) + assert isinstance(geopandas_container, GeoPandasGridContainer) - common_cols = list(set(attr.columns).intersection(grid_attr.columns)) + # check that content of geodataframes is the same as content of original + # dataframes + for attr_str in attrs: + grid = getattr(geopandas_container, "grid") + attr = getattr(geopandas_container, attr_str) + grid_attr = getattr(grid, attr_str.replace("_gdf", "_df")) - assert_frame_equal( - attr[common_cols], grid_attr[common_cols], check_names=False - ) + assert isinstance(attr, GeoDataFrame) + + common_cols = list(set(attr.columns).intersection(grid_attr.columns)) + + assert_frame_equal( + attr[common_cols].sort_index(), + grid_attr[common_cols].sort_index(), + check_names=False, + ) def test_from_csv(self): """ @@ -1720,7 +1739,7 @@ def test_connect_to_lv(self): loads_before = self.edisgo.topology.loads_df test_hp = { - "p_set": 0.3, + "p_set": 0.1, "geom": geom, "voltage_level": 6, "mvlv_subst_id": 6, @@ -1751,7 +1770,7 @@ def test_connect_to_lv(self): new_line_df.loc[new_line_df.index[0], ["bus0", "bus1"]] ) # check new heat pump - assert self.edisgo.topology.loads_df.at[comp_name, "p_set"] == 0.3 + assert self.edisgo.topology.loads_df.at[comp_name, "p_set"] == 0.1 # ############# storage unit ################# # test existing substation ID (voltage level 7) @@ -1909,3 +1928,25 @@ def test_check_integrity(self, caplog): assert "There are lines with very short line lengths" in caplog.text assert "Very small values for impedance of lines" and line in caplog.text caplog.clear() + + def test_find_meshes(self, caplog: pytest.LogCaptureFixture): + meshes = Topology.find_meshes(self.edisgo) + assert not meshes + self.edisgo.topology.add_line( + "Bus_GeneratorFluctuating_2", + "Bus_GeneratorFluctuating_6", + 0.1, + x=0.1, + r=0.1, + ) + meshes = Topology.find_meshes(self.edisgo) + assert len(meshes) == 1 + assert "Bus_GeneratorFluctuating_2" in meshes[0] + assert "Bus_GeneratorFluctuating_6" in meshes[0] + self.edisgo.topology.add_line( + "Bus_BranchTee_LVGrid_2_3", "Bus_BranchTee_LVGrid_3_3", 0.1, x=0.1, r=0.1 + ) + meshes = Topology.find_meshes(self.edisgo) + assert len(meshes) == 2 + assert "Bus_BranchTee_LVGrid_2_3" in meshes[1] + assert "Grid contains mesh(es)." in caplog.text diff --git a/tests/tools/test_geopandas_helper.py b/tests/tools/test_geopandas_helper.py new file mode 100644 index 000000000..a9aca9427 --- /dev/null +++ b/tests/tools/test_geopandas_helper.py @@ -0,0 +1,65 @@ +import pytest + +from edisgo import EDisGo +from edisgo.tools import geopandas_helper + + +class TestGeopandasHelper: + @classmethod + def setup_class(self): + self.edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) + + def test_to_geopandas(self): + # further tests of this function are conducted in test_topology.py + # test MV grid + data = geopandas_helper.to_geopandas(self.edisgo.topology.mv_grid, 4326) + assert data.buses_gdf.shape[0] == self.edisgo.topology.mv_grid.buses_df.shape[0] + assert ( + data.buses_gdf.shape[1] + == self.edisgo.topology.mv_grid.buses_df.shape[1] + 1 - 2 + ) + assert "geometry" in data.buses_gdf.columns + + assert data.lines_gdf.shape[0] == self.edisgo.topology.mv_grid.lines_df.shape[0] + assert ( + data.lines_gdf.shape[1] + == self.edisgo.topology.mv_grid.lines_df.shape[1] + 2 + ) + assert "geometry" in data.lines_gdf.columns + + assert data.loads_gdf.shape[0] == self.edisgo.topology.mv_grid.loads_df.shape[0] + assert ( + data.loads_gdf.shape[1] + == self.edisgo.topology.mv_grid.loads_df.shape[1] + 2 + ) + assert "geometry" in data.loads_gdf.columns + + assert ( + data.generators_gdf.shape[0] + == self.edisgo.topology.mv_grid.generators_df.shape[0] + ) + assert ( + data.generators_gdf.shape[1] + == self.edisgo.topology.mv_grid.generators_df.shape[1] + 2 + ) + assert "geometry" in data.generators_gdf.columns + + assert ( + data.storage_units_gdf.shape[0] + == self.edisgo.topology.mv_grid.storage_units_df.shape[0] + ) + assert ( + data.storage_units_gdf.shape[1] + == self.edisgo.topology.mv_grid.storage_units_df.shape[1] + 2 + ) + assert "geometry" in data.storage_units_gdf.columns + + assert ( + data.transformers_gdf.shape[0] + == self.edisgo.topology.mv_grid.transformers_df.shape[0] + ) + assert ( + data.transformers_gdf.shape[1] + == self.edisgo.topology.mv_grid.transformers_df.shape[1] + 2 + ) + assert "geometry" in data.transformers_gdf.columns diff --git a/tests/tools/test_tools.py b/tests/tools/test_tools.py index 40c34a63b..66216ca6d 100644 --- a/tests/tools/test_tools.py +++ b/tests/tools/test_tools.py @@ -30,6 +30,184 @@ def test_calculate_line_reactance(self): data = tools.calculate_line_reactance(np.array([2, 3]), 3, 2) assert_allclose(data, np.array([1.88496 / 2, 2.82743 / 2]), rtol=1e-5) + def test_calculate_voltage_diff_pu_per_line(self): + correct_value_positive_sign = 0.03261946832784687 + correct_value_negative_sign = 0.06008053167215312 + r_total = 0.412 + x_total = 0.252 + + # test generator, float + data = tools.calculate_voltage_diff_pu_per_line( + s_max=50, + r_total=r_total, + x_total=x_total, + v_nom=20, + q_sign=-1, + power_factor=0.9, + ) + assert np.isclose(data, correct_value_positive_sign) + # test generator, array + data = tools.calculate_voltage_diff_pu_per_line( + s_max=np.array([50, 50]), + r_total=np.array([r_total, r_total]), + x_total=np.array([x_total, x_total]), + v_nom=20, + q_sign=-1, + power_factor=0.9, + ) + assert_allclose( + data, + np.array([correct_value_positive_sign, correct_value_positive_sign]), + rtol=1e-5, + ) + # test generator, float, higher voltage + data = tools.calculate_voltage_diff_pu_per_line( + s_max=50, + r_total=r_total, + x_total=x_total, + v_nom=40, + q_sign=-1, + power_factor=0.9, + ) + assert np.isclose(data, correct_value_positive_sign / 4) + # test generator, array, larger cable + data = tools.calculate_voltage_diff_pu_per_line( + s_max=np.array([100, 100]), + r_total=np.array([r_total, r_total]), + x_total=np.array([x_total, x_total]), + v_nom=np.array([20, 20]), + q_sign=-1, + power_factor=0.9, + ) + assert_allclose( + data, + np.array( + [correct_value_positive_sign * 2, correct_value_positive_sign * 2] + ), + rtol=1e-5, + ) + # test generator, capacitive + data = tools.calculate_voltage_diff_pu_per_line( + s_max=100, + r_total=r_total, + x_total=x_total, + v_nom=20, + q_sign=1, + power_factor=0.9, + ) + assert np.isclose(data, correct_value_negative_sign * 2) + # test load, capacitive + data = tools.calculate_voltage_diff_pu_per_line( + s_max=100, + r_total=r_total, + x_total=x_total, + v_nom=20, + q_sign=-1, + power_factor=0.9, + ) + assert np.isclose(data, correct_value_positive_sign * 2) + + # test the examples from VDE-AR-N 4105 attachment D + data = tools.calculate_voltage_diff_pu_per_line( + s_max=0.02, + r_total=0.2001, + x_total=0.1258, + v_nom=0.4, + q_sign=-1, + power_factor=1, + ) + assert np.isclose(data, 0.025, rtol=1e-2) + + data = tools.calculate_voltage_diff_pu_per_line( + s_max=0.022, + r_total=0.2001, + x_total=0.1258, + v_nom=0.4, + q_sign=-1, + power_factor=0.9, + ) + assert np.isclose(data, 0.0173, rtol=1e-2) + + def test_calculate_voltage_diff_pu_per_line_from_type(self): + correct_value_negative_sign = 0.4916578234319946 * 1e-2 + correct_value_positive_sign = 0.017583421765680056 + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names="NA2XS(FL)2Y 3x1x300 RM/25", + length=1, + num_parallel=1, + v_nom=20, + s_max=50, + component_type="generator", + ) + assert np.isclose(data, correct_value_negative_sign) + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names=np.array( + ["NA2XS(FL)2Y 3x1x300 RM/25", "NA2XS(FL)2Y 3x1x300 RM/25"] + ), + length=1, + num_parallel=1, + v_nom=20, + s_max=50, + component_type="generator", + ) + assert_allclose( + data, + np.array([correct_value_negative_sign, correct_value_negative_sign]), + rtol=1e-5, + ) + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names="NA2XS(FL)2Y 3x1x300 RM/25", + length=2, + num_parallel=1, + v_nom=20, + s_max=50, + component_type="generator", + ) + assert np.isclose(data, 2 * correct_value_negative_sign) + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names=np.array( + ["NA2XS(FL)2Y 3x1x300 RM/25", "NA2XS(FL)2Y 3x1x300 RM/25"] + ), + length=2, + num_parallel=1, + v_nom=20, + s_max=50, + component_type="generator", + ) + assert_allclose( + data, + np.array( + [2 * correct_value_negative_sign, 2 * correct_value_negative_sign] + ), + rtol=1e-5, + ) + + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names="NA2XS(FL)2Y 3x1x300 RM/25", + length=1, + num_parallel=2, + v_nom=20, + s_max=50, + component_type="generator", + ) + assert np.isclose(data, correct_value_negative_sign / 2) + + data = tools.calculate_voltage_diff_pu_per_line_from_type( + edisgo_obj=self.edisgo, + cable_names="NA2XS(FL)2Y 3x1x300 RM/25", + length=1, + num_parallel=2, + v_nom=20, + s_max=50, + component_type="conventional_load", + ) + assert np.isclose(data, correct_value_positive_sign / 2) + def test_calculate_line_resistance(self): # test single line data = tools.calculate_line_resistance(2, 3, 1) @@ -97,18 +275,103 @@ def test_drop_duplicated_columns(self): assert (check_df.loc[:, "a"] == [4, 5, 6]).all() def test_select_cable(self): - cable_data, num_parallel_cables = tools.select_cable(self.edisgo, "mv", 5.1) + # no length given + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "mv", + 5.1, + ) assert cable_data.name == "NA2XS2Y 3x1x150 RE/25" assert num_parallel_cables == 1 - cable_data, num_parallel_cables = tools.select_cable(self.edisgo, "mv", 40) + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "mv", + 40, + ) assert cable_data.name == "NA2XS(FL)2Y 3x1x500 RM/35" assert num_parallel_cables == 2 - cable_data, num_parallel_cables = tools.select_cable(self.edisgo, "lv", 0.18) + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "lv", + 0.18, + ) assert cable_data.name == "NAYY 4x1x150" assert num_parallel_cables == 1 + # length given + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "mv", + 5.1, + length=2, + component_type="conventional_load", + ) + assert cable_data.name == "NA2XS2Y 3x1x150 RE/25" + assert num_parallel_cables == 1 + + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "mv", + 40, + length=1, + component_type="conventional_load", + ) + assert cable_data.name == "NA2XS(FL)2Y 3x1x500 RM/35" + assert num_parallel_cables == 2 + + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "lv", + 0.18, + length=1, + component_type="conventional_load", + ) + assert cable_data.name == "NAYY 4x1x300" + assert num_parallel_cables == 5 + + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "lv", + 0.18, + length=1, + max_voltage_diff=0.01, + max_cables=100, + component_type="conventional_load", + ) + assert cable_data.name == "NAYY 4x1x300" + assert num_parallel_cables == 14 + + cable_data, num_parallel_cables = tools.select_cable( + self.edisgo, + "lv", + 0.18, + length=1, + max_voltage_diff=0.01, + max_cables=100, + component_type="generator", + ) + assert cable_data.name == "NAYY 4x1x300" + assert num_parallel_cables == 8 + + try: + tools.select_cable( + self.edisgo, + "lv", + 0.18, + length=1, + max_voltage_diff=0.01, + max_cables=100, + component_type="fail", + ) + except ValueError as e: + assert ( + str(e) == "Specified component type is not valid. " + "Must either be 'generator', 'conventional_load', 'charging_point', " + "'heat_pump' or 'storage_unit'." + ) + def test_get_downstream_buses(self): # ######## test with LV bus ######## buses_downstream = tools.get_downstream_buses(