diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index 3930a5659..d47eb9de4 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -1004,7 +1004,7 @@ def analyze( Conducts a static, non-linear power flow analysis using `PyPSA `_ + non-linear-power-flow>`_ and writes results (active, reactive and apparent power as well as current on lines and voltages at buses) to :class:`~.network.results.Results` (e.g. :attr:`~.network.results.Results.v_res` for voltages). diff --git a/edisgo/io/ding0_import.py b/edisgo/io/ding0_import.py index 326553111..d14ee5046 100644 --- a/edisgo/io/ding0_import.py +++ b/edisgo/io/ding0_import.py @@ -72,8 +72,12 @@ def sort_hvmv_transformer_buses(transformers_df): grid.import_from_csv_folder(path) # write dataframes to edisgo_obj - edisgo_obj.topology.buses_df = grid.buses[edisgo_obj.topology.buses_df.columns] - edisgo_obj.topology.lines_df = grid.lines[edisgo_obj.topology.lines_df.columns] + edisgo_obj.topology.buses_df = grid.buses.loc[ + :, edisgo_obj.topology.buses_df.columns + ] + edisgo_obj.topology.lines_df = grid.lines.loc[ + :, edisgo_obj.topology.lines_df.columns + ] if legacy_ding0_grids: logger.debug("Use ding0 legacy grid import.") # rename column peak_load to p_set diff --git a/edisgo/io/electromobility_import.py b/edisgo/io/electromobility_import.py index 888529599..e3769ae93 100644 --- a/edisgo/io/electromobility_import.py +++ b/edisgo/io/electromobility_import.py @@ -371,10 +371,49 @@ def assure_minimum_potential_charging_parks( edisgo_obj: EDisGo, potential_charging_parks_gdf: gpd.GeoDataFrame, **kwargs, -): - # ensure minimum number of potential charging parks per car +) -> gpd.GeoDataFrame: + """ + Ensures that there is a minimum number of potential charging parks + relative to the number of electric vehicles for each use case (home, work, + public, and high-power charging). This function adjusts the potential + charging parks by duplicating entries if necessary. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing grid and electromobility data. + potential_charging_parks_gdf : :geopandas:`geopandas.GeoDataFrame` + A GeoDataFrame containing potential charging park locations + and their attributes. + **kwargs : dict, optional + Additional keyword arguments to specify the grid connection to car + rate for different use cases. Expected keys: + - 'gc_to_car_rate_home' : float, optional, default 0.5 + - 'gc_to_car_rate_work' : float, optional, default 0.25 + - 'gc_to_car_rate_public' : float, optional, default 0.1 + - 'gc_to_car_rate_hpc' : float, optional, default 0.005 + + Returns + ------- + :geopandas:`geopandas.GeoDataFrame` + A GeoDataFrame with adjusted potential charging park locations, + ensuring the minimum required number of parks per use case. + + Notes + ----- + - The function may duplicate potential charging park entries if the + current number does not meet the required rate relative to the number + of cars for each use case. + - If no charging parks are available for a use case, a random sample of + 10% of public charging parks will be duplicated and assigned to the + missing use case. + """ + + # Calculate the total number of unique cars num_cars = len(edisgo_obj.electromobility.charging_processes_df.car_id.unique()) + # Iterate over each use case to ensure the minimum number of potential charging + # parks for use_case in USECASES: if use_case == "home": gc_to_car_rate = kwargs.get("gc_to_car_rate_home", 0.5) @@ -385,30 +424,33 @@ def assure_minimum_potential_charging_parks( elif use_case == "hpc": gc_to_car_rate = kwargs.get("gc_to_car_rate_hpc", 0.005) + # Filter the GeoDataFrame for the current use case use_case_gdf = potential_charging_parks_gdf.loc[ potential_charging_parks_gdf.use_case == use_case ] + # Count the number of potential grid connections (GCs) for the use case num_gcs = len(use_case_gdf) - # if tracbev doesn't provide possible grid connections choose a - # random public potential charging park and duplicate + # Handle cases where no potential charging parks exist for a specific use case if num_gcs == 0: logger.warning( - f"There are no potential charging parks for use case {use_case}. " - f"Therefore 10 % of public potential charging parks are duplicated " - f"randomly and assigned to use case {use_case}." + f"No potential charging parks found for use case '{use_case}'. " + f"Duplicating 10% of public potential charging parks and assigning " + f"them to use case '{use_case}'." ) + # Randomly sample 10% of public charging parks and assign to the current use + # case public_gcs = potential_charging_parks_gdf.loc[ potential_charging_parks_gdf.use_case == "public" ] - random_gcs = public_gcs.sample( int(np.ceil(len(public_gcs) / 10)), random_state=edisgo_obj.topology.mv_grid.id, ).assign(use_case=use_case) + # Append the new entries to the GeoDataFrame potential_charging_parks_gdf = pd.concat( [ potential_charging_parks_gdf, @@ -421,21 +463,22 @@ def assure_minimum_potential_charging_parks( ] num_gcs = len(use_case_gdf) - # escape zero division + # Calculate the actual grid connection to car rate actual_gc_to_car_rate = np.Infinity if num_cars == 0 else num_gcs / num_cars - # duplicate potential charging parks until desired quantity is ensured + # Duplicate potential charging parks until the required rate is met max_it = 50 n = 0 - while actual_gc_to_car_rate < gc_to_car_rate and n < max_it: logger.info( f"Duplicating potential charging parks to meet the desired grid " - f"connections to cars rate of {gc_to_car_rate*100:.2f} % for use case " - f"{use_case}. Iteration: {n+1}." + f"connections to cars rate of {gc_to_car_rate * 100:.2f}% for use case " + f"'{use_case}'. Iteration: {n + 1}." ) if actual_gc_to_car_rate * 2 < gc_to_car_rate: + # Double the number of potential charging parks by duplicating the + # entire subset potential_charging_parks_gdf = pd.concat( [ potential_charging_parks_gdf, @@ -443,17 +486,15 @@ def assure_minimum_potential_charging_parks( ], ignore_index=True, ) - else: + # Calculate the number of extra grid connections needed and sample them extra_gcs = ( int(np.ceil(num_gcs * gc_to_car_rate / actual_gc_to_car_rate)) - num_gcs ) - extra_gdf = use_case_gdf.sample( n=extra_gcs, random_state=edisgo_obj.topology.mv_grid.id ) - potential_charging_parks_gdf = pd.concat( [ potential_charging_parks_gdf, @@ -462,23 +503,21 @@ def assure_minimum_potential_charging_parks( ignore_index=True, ) + # Recalculate the grid connection to car rate after duplication use_case_gdf = potential_charging_parks_gdf.loc[ potential_charging_parks_gdf.use_case == use_case ] - num_gcs = len(use_case_gdf) - actual_gc_to_car_rate = num_gcs / num_cars n += 1 - # sort GeoDataFrame + # Sort the GeoDataFrame by use case, AGS, and user-centric weight potential_charging_parks_gdf = potential_charging_parks_gdf.sort_values( by=["use_case", "ags", "user_centric_weight"], ascending=[True, True, False] ).reset_index(drop=True) - # in case of polygons use the centroid as potential charging parks point - # and set crs to match edisgo object + # For polygon geometries, use the centroid as the charging park point and match CRS return ( potential_charging_parks_gdf.assign( geometry=potential_charging_parks_gdf.geometry.representative_point() @@ -523,70 +562,83 @@ def distribute_charging_demand(edisgo_obj, **kwargs): def get_weights_df(edisgo_obj, potential_charging_park_indices, **kwargs): """ - Get weights per potential charging point for a given set of grid connection indices. + Calculate the weights for potential charging points based on user and grid + preferences. + + This function determines the attractiveness of potential charging parks by + calculating weights that reflect either user convenience or grid stability, + or a combination of both. The weights are adjusted based on specified + parameters and are returned as a DataFrame. Parameters ---------- edisgo_obj : :class:`~.EDisGo` + The eDisGo object containing relevant grid and electromobility data. potential_charging_park_indices : list - List of potential charging parks indices + A list of indices identifying the potential charging parks to be + evaluated. Other Parameters - ----------------- - mode : str - Only use user friendly weights ("user_friendly") or combine with - grid friendly weights ("grid_friendly"). Default: "user_friendly". - user_friendly_weight : float - Weight of user friendly weight if mode "grid_friendly". Default: 0.5. - distance_weight: float - Grid friendly weight is a combination of the installed capacity of - generators and loads within a LV grid and the distance towards the - nearest substation. This parameter sets the weight for the distance - parameter. Default: 1/3. + ---------------- + mode : str, optional + Determines the weighting approach. "user_friendly" uses only user-centric + weights, while "grid_friendly" combines user-centric and grid-centric + weights. Default is "user_friendly". + user_friendly_weight : float, optional + The weight assigned to user-centric preferences when using the + "grid_friendly" mode. Default is 0.5. + distance_weight : float, optional + Weighting factor for the distance to the nearest substation when using + the "grid_friendly" mode. This affects the grid-centric weight + calculation. Default is 1/3. Returns ------- :pandas:`pandas.DataFrame` - DataFrame with numeric weights - + A DataFrame containing the calculated weights for each potential charging + park, reflecting either user or grid preferences, or a combination thereof. + + Notes + ----- + - The grid-friendly weighting takes into account the installed capacity of + generators and loads within a low-voltage (LV) grid, as well as the + distance to the nearest substation. + - If the mode is set to "grid_friendly", the function will combine user + preferences with grid stability considerations to determine the final + weights. + + Raises + ------ + ValueError + If an invalid mode is provided, an exception is raised. """ def _get_lv_grid_weights(): """ - DataFrame containing technical data of LV grids. + Calculate technical weights for LV grids. + + This internal function creates a DataFrame containing technical + information about LV grids, which is used to calculate grid-centric + weights for potential charging parks. Returns - -------- + ------- :pandas:`pandas.DataFrame` - Columns of the DataFrame are: - peak_generation_capacity : float - Cumulative peak generation capacity of generators in the network in - MW. - - p_set : float - Cumulative peak load of loads in the network in MW. - - substation_capacity : float - Cumulative capacity of transformers to overlaying network. - - generators_weight : float - Weighting used in grid friendly siting of public charging points. - In the case of generators the weight is defined by dividing the - peak_generation_capacity by substation_capacity and norming the - results from 0 .. 1. A higher weight is more attractive. - - loads_weight : float - Weighting used in grid friendly siting of public charging points. - In the case of loads the weight is defined by dividing the - p_set by substation_capacity and norming the results from 0 .. 1. - The result is then substracted from 1 as the higher the p_set is - in relation to the substation_capacity the less attractive this LV - grid is for new loads from a grid perspective. A higher weight is - more attractive. + A DataFrame with the following columns: + - peak_generation_capacity: Cumulative peak generation capacity of + generators in the LV grid (in MW). + - p_set: Cumulative peak load of loads in the LV grid (in MW). + - substation_capacity: Cumulative capacity of transformers + connecting the LV grid to the overlaying network. + - generators_weight: Weight reflecting the attractiveness of the + LV grid for new generation capacity. + - loads_weight: Weight reflecting the attractiveness of the LV grid + for new loads, from a grid stability perspective. """ lv_grids = list(edisgo_obj.topology.mv_grid.lv_grids) + # Create a DataFrame to store LV grid weights lv_grids_df = pd.DataFrame( index=[_._id for _ in lv_grids], columns=[ @@ -598,14 +650,15 @@ def _get_lv_grid_weights(): ], ) + # Populate the DataFrame with relevant data lv_grids_df.peak_generation_capacity = [ _.peak_generation_capacity for _ in lv_grids ] - lv_grids_df.substation_capacity = [ _.transformers_df.s_nom.sum() for _ in lv_grids ] + # Normalize generator weights min_max_scaler = preprocessing.MinMaxScaler() lv_grids_df.generators_weight = lv_grids_df.peak_generation_capacity.divide( lv_grids_df.substation_capacity @@ -616,23 +669,27 @@ def _get_lv_grid_weights(): lv_grids_df.p_set = [_.p_set for _ in lv_grids] + # Normalize load weights and normalize them lv_grids_df.loads_weight = lv_grids_df.p_set.divide( lv_grids_df.substation_capacity ) lv_grids_df.loads_weight = 1 - min_max_scaler.fit_transform( lv_grids_df.loads_weight.values.reshape(-1, 1) ) + return lv_grids_df mode = kwargs.get("mode", "user_friendly") if mode == "user_friendly": + # Retrieve user-centric weights for the selected charging parks weights = [ _.user_centric_weight for _ in edisgo_obj.electromobility.potential_charging_parks if _.id in potential_charging_park_indices ] elif mode == "grid_friendly": + # Prepare data for grid-centric weight calculation potential_charging_parks = list( edisgo_obj.electromobility.potential_charging_parks ) @@ -648,28 +705,30 @@ def _get_lv_grid_weights(): generators_weight_factor = kwargs.get("generators_weight_factor", 0.5) loads_weight_factor = 1 - generators_weight_factor + # Combine generator and load weights to calculate grid-centric weights combined_weights = ( generators_weight_factor * lv_grids_df["generators_weight"] + loads_weight_factor * lv_grids_df["loads_weight"] ) + # Retrieve the IDs of the nearest LV grids for each potential charging park lv_grid_ids = [ _.nearest_substation["lv_grid_id"] for _ in potential_charging_parks ] + # Map the combined weights to the corresponding charging parks load_and_generator_capacity_weights = [ combined_weights.at[lv_grid_id] for lv_grid_id in lv_grid_ids ] - # fmt: off + # Extract the distance weights from the eDisGo object distance_weights = ( - edisgo_obj.electromobility._potential_charging_parks_df.distance_weight - .tolist() + edisgo_obj.electromobility._potential_charging_parks_df.distance_weight.tolist() # noqa: E501 ) - # fmt: on distance_weight = kwargs.get("distance_weight", 1 / 3) + # Combine the distance weights with the load and generator capacity weights grid_friendly_weights = [ (1 - distance_weight) * load_and_generator_capacity_weights[i] + distance_weight * distance_weights[i] @@ -678,17 +737,18 @@ def _get_lv_grid_weights(): user_friendly_weight = kwargs.get("user_friendly_weight", 0.5) + # Final combined weights considering both user and grid preferences weights = [ (1 - user_friendly_weight) * grid_friendly_weights[i] + user_friendly_weight * user_friendly_weights[i] for i in range(len(grid_friendly_weights)) ] - else: raise ValueError( - "Provided mode is not valid, needs to be 'user_friendly' or " + "Provided mode is not valid. It must be either 'user_friendly' or " "'grid_friendly'." ) + return pd.DataFrame(weights) diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index 1eb0a9272..b2509e869 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -1910,12 +1910,16 @@ def connect_to_mv(self, edisgo_object, comp_data, comp_type="generator"): # add component to newly created bus comp_data.pop("geom") if comp_type == "generator": + comp_data.pop("type", None) comp_name = self.add_generator(bus=bus, **comp_data) elif comp_type == "charging_point": + comp_data.pop("type", None) comp_name = self.add_load(bus=bus, type="charging_point", **comp_data) elif comp_type == "heat_pump": + comp_data.pop("type", None) comp_name = self.add_load(bus=bus, type="heat_pump", **comp_data) else: + comp_data.pop("type", None) comp_name = self.add_storage_unit(bus=bus, **comp_data) # ===== voltage level 4: component is connected to MV station ===== @@ -2284,7 +2288,9 @@ def _choose_random_substation_id(): "component is therefore connected to random LV bus." ) bus = random.choice( - lv_grid.buses_df[~lv_grid.buses_df.in_building.astype(bool)].index + lv_grid.buses_df[ + ~lv_grid.buses_df.in_building.astype(bool) + ].index.tolist() ) comp_data.pop("geom", None) comp_data.pop("p") @@ -2340,10 +2346,13 @@ def _choose_random_substation_id(): def connect_to_lv_based_on_geolocation( self, edisgo_object, - comp_data, - comp_type, - max_distance_from_target_bus=0.02, - ): + comp_data: dict, + comp_type: str, + max_distance_from_target_bus: float = 0.02, + allowed_number_of_comp_per_bus: int = 2, + allow_mv_connection: bool = False, + factor_mv_connection: float = 3.0, + ) -> str: """ Add and connect new component to LV grid topology based on its geolocation. @@ -2364,6 +2373,53 @@ def connect_to_lv_based_on_geolocation( `max_distance_from_target_bus`. Otherwise, the new component is directly connected to the nearest bus. + In the following are some more details on the methodology: + + * Voltage level 6: + If the voltage level is 6, the component is connected to the closest + MV/LV substation or MV bus, depending on whether MV connection is + allowed or not. Therefore, the distance to the substations and MV buses + is calculated and the closest one is chosen as target bus. + If the distance is greater than the specified maximum distance, a new + bus is created for the component. + + * Voltage level 7: + If the voltage level is 7, the component is connected to the closest + LV bus. Two main cases can be distinguished: + + * No MV connection allowed: + If the distance to the closest LV bus is less than the specified + maximum distance `max_distance_from_target_bus`, the component is + connected to the closest LV bus. + If the distance is greater, a new bus is created for the component. + If there are already components of the same type connected to the + target bus, the component is connected to the closest LV bus with + fewer connected components of the same type within the maximum + distance. If no such bus is found, the component is connected to + the closest LV bus again. If all buses within the allowed distance + have equal or more components of the same type connected to them + than allowed, the component is connected to a new LV bus. + + * MV connection allowed: + If the distance to the closest LV bus is less than the specified + maximum distance `max_distance_from_target_bus`, the component is + connected to the closest LV bus. + If the distance is greater, the distance to the closest MV bus is + calculated. If the distance to the closest MV bus multiplied with + the factor `factor_mv_connection` is less than the distance to the + closest LV bus, the component is connected to the closest MV bus. + There is no restriction on the number of components of the same type + connected to the MV bus. If the distance is greater, the component + is connected to a new LV bus. + + * Buses the components can be connected to: + - For voltage level 6: Components can be connected to MV/LV substations. + If MV connection is allowed, components can also be connected to + MV buses. + - For voltage level 7: Components can be connected to LV buses. If MV + connection is allowed, components can also be connected + to MV buses and MV/LV substations. + Parameters ---------- edisgo_object : :class:`~.EDisGo` @@ -2390,6 +2446,16 @@ def connect_to_lv_based_on_geolocation( before a new bus is created. If the new component is closer to the target bus than the maximum specified distance, it is directly connected to that target bus. Default: 0.1. + allowed_number_of_comp_per_bus : int + Specifies, how many components of the same type are at most allowed to be + placed at the same bus. Default: 2. + allow_mv_connection : bool + Specifies whether the component can be connected to the MV grid in case + the closest LV bus is too far away. Default: False. + factor_mv_connection : float + Specifies the factor by which the distance to the closest MV bus is + multiplied to decide whether the component is connected to the MV grid + instead of the LV grid. Default: 3.0. Returns ------- @@ -2399,9 +2465,162 @@ def connect_to_lv_based_on_geolocation( :attr:`~.network.topology.Topology.loads_df` or :attr:`~.network.topology.Topology.storage_units_df`, depending on component type. - """ + def validate_voltage_level(voltage_level): + if voltage_level not in [6, 7]: + raise ValueError( + f"Voltage level must either be 6 or 7 but given voltage level " + f"is {voltage_level}." + ) + + def get_add_function(comp_type): + add_func_map = { + "generator": self.add_generator, + "charging_point": self.add_load, + "heat_pump": self.add_load, + "storage_unit": self.add_storage_unit, + } + add_func = add_func_map.get(comp_type) + if add_func is None: + raise ValueError( + f"Provided component type {comp_type} is not valid. Must either be " + f"'generator', 'charging_point', 'heat_pump' or 'storage_unit'." + ) + return add_func + + def handle_voltage_level_6(): + # get substations and MV buses + substations = self.buses_df.loc[self.transformers_df.bus1.unique()] + if allow_mv_connection: + mv_buses = self.buses_df.loc[self.mv_grid.buses_df.index] + else: + mv_buses = pd.DataFrame() + all_buses = pd.concat([substations, mv_buses]) + # calculate distance to possible buses + target_bus, target_bus_distance = geo.find_nearest_bus( + geolocation, all_buses + ) + if target_bus in substations.index: + # if distance is larger than allowed, create new bus and connect to + # station via a new line + if target_bus_distance > max_distance_from_target_bus: + bus = self._connect_to_lv_bus( + edisgo_object, target_bus, comp_type, comp_data + ) + else: + mvlv_subst_id = self.buses_df.loc[target_bus].loc["lv_grid_id"] + comp_data["mvlv_subst_id"] = mvlv_subst_id + comp_name = add_func(bus=target_bus, **comp_data) + return None, comp_name + else: + comp_name = self.connect_to_mv(edisgo_object, comp_data, comp_type) + return None, comp_name + return bus, None + + def handle_voltage_level_7(): + if allow_mv_connection: + mv_buses = self.buses_df.loc[self.mv_grid.buses_df.index] + mv_buses = geo.calculate_distance_to_buses_df(geolocation, mv_buses) + mv_buses_masked = mv_buses.loc[ + mv_buses.distance < max_distance_from_target_bus + ] + + lv_buses = self.buses_df.drop(self.mv_grid.buses_df.index) + if comp_type == "charging_point": + if comp_data["sector"] == "home": + lv_loads = self.loads_df[ + self.loads_df.sector.isin(["residential", "home"]) + ] + lv_loads = lv_loads.loc[ + ~lv_loads.bus.isin(self.mv_grid.buses_df.index) + ] + elif comp_data["sector"] == "work": + lv_loads = self.loads_df[ + self.loads_df.sector.isin(["industrial", "cts", "agricultural"]) + ] + lv_loads = lv_loads.loc[ + ~lv_loads.bus.isin(self.mv_grid.buses_df.index) + ] + else: + lv_loads = self.loads_df + lv_loads = lv_loads.loc[ + ~lv_loads.bus.isin(self.mv_grid.buses_df.index) + ] + lv_buses = lv_buses.loc[~lv_buses.in_building] + lv_buses = lv_buses.loc[lv_loads.bus] + + lv_buses = geo.calculate_distance_to_buses_df(geolocation, lv_buses) + lv_buses_masked = lv_buses.loc[ + lv_buses.distance < max_distance_from_target_bus + ].copy() + + # if no bus is within the allowed distance, connect to new bus + if len(lv_buses_masked) == 0: + # connect to MV if this is the best option + if ( + allow_mv_connection + and len(mv_buses_masked) > 0 + and mv_buses.distance.min() * factor_mv_connection + < lv_buses.distance.min() + ): + mv_buses_masked = mv_buses[ + mv_buses.distance == mv_buses.distance.min() + ] + target_bus = mv_buses_masked.loc[mv_buses_masked.distance.idxmin()] + comp_name = self.connect_to_mv(edisgo_object, comp_data, comp_type) + return None, comp_name + else: + # if distance is larger than allowed, create new bus and connect to + # the closest bus via a new line + + target_bus = self._connect_to_lv_bus( + edisgo_object, lv_buses.distance.idxmin(), comp_type, comp_data + ) + lv_buses_masked = pd.DataFrame(self.buses_df.loc[target_bus]).T + return target_bus, None + + # if LV bus is within distance, where no new bus needs to be created, check + # the number of already connected buses, so to not connect too many of the + # same components to that bus + comp_df = { + "charging_point": self.charging_points_df, + "generator": self.generators_df, + "heat_pump": self.loads_df[self.loads_df.type == "heat_pump"], + "storage_unit": self.storage_units_df, + }.get(comp_type) + # determine number of connected components per bus + comp_type_counts = ( + comp_df.loc[comp_df.bus.isin(lv_buses_masked.index)] + .groupby("bus") + .size() + ) + lv_buses_masked.loc[:, "num_comps"] = ( + lv_buses_masked.index.map(comp_type_counts).fillna(0).astype(int) + ) + # get buses where the least number of components of the given type is + # connected + lv_buses_masked = lv_buses_masked[ + lv_buses_masked.num_comps == lv_buses_masked.num_comps.min() + ] + + if lv_buses_masked.num_comps.min() >= allowed_number_of_comp_per_bus: + # if all buses within the allowed distance have equal or more + # components of the same type connected to them than allowed, + # connect to new bus + target_bus = self._connect_to_lv_bus( + edisgo_object, lv_buses.distance.idxmin(), comp_type, comp_data + ) + if isinstance(target_bus, str): + return target_bus, None + elif isinstance(target_bus, pd.DataFrame): + return target_bus.index[0], None + + else: + target_bus = lv_buses_masked.loc[lv_buses_masked.distance.idxmin()] + return target_bus.name, None + + # Ensure 'p' is in comp_data, defaulting to 'p_set' or 'p_nom' if "p" not in comp_data.keys(): comp_data["p"] = ( comp_data["p_set"] @@ -2409,51 +2628,38 @@ def connect_to_lv_based_on_geolocation( else comp_data["p_nom"] ) - voltage_level = comp_data.pop("voltage_level") - if voltage_level not in [6, 7]: - raise ValueError( - f"Voltage level must either be 6 or 7 but given voltage level " - f"is {voltage_level}." - ) + # Extract and validate voltage level + voltage_level = comp_data.get("voltage_level") + validate_voltage_level(voltage_level) geolocation = comp_data.get("geom") - if comp_type == "generator": - add_func = self.add_generator - elif comp_type == "charging_point" or comp_type == "heat_pump": - add_func = self.add_load + # Determine the appropriate add function based on component type + add_func = get_add_function(comp_type) + + # Set the component type in comp_data if necessary + if comp_type in ["charging_point", "heat_pump"]: comp_data["type"] = comp_type - elif comp_type == "storage_unit": - add_func = self.add_storage_unit - else: - logger.error(f"Component type {comp_type} is not a valid option.") - return + elif comp_type in ["generator", "storage_unit"]: + comp_data["p_nom"] = comp_data["p"] - # find the nearest substation or LV bus + # Handle different voltage levels if voltage_level == 6: - substations = self.buses_df.loc[self.transformers_df.bus1.unique()] - target_bus, target_bus_distance = geo.find_nearest_bus( - geolocation, substations - ) - else: - lv_buses = self.buses_df.drop(self.mv_grid.buses_df.index) - target_bus, target_bus_distance = geo.find_nearest_bus( - geolocation, lv_buses - ) + bus, comp_name = handle_voltage_level_6() + if comp_name is not None: + return comp_name + elif voltage_level == 7: + bus, comp_name = handle_voltage_level_7() + if comp_name is not None: + return comp_name - # check distance from target bus - if target_bus_distance > max_distance_from_target_bus: - # if target bus is too far away from the component, connect the component - # via a new bus - bus = self._connect_to_lv_bus( - edisgo_object, target_bus, comp_type, comp_data - ) - else: - # if target bus is very close to the component, the component is directly - # connected at the target bus - bus = target_bus - comp_data.pop("geom") - comp_data.pop("p") + # Remove unnecessary keys from comp_data + comp_data.pop("geom", None) + comp_data.pop("p", None) + comp_data.pop("voltage_level", None) + + # Add the component to the grid comp_name = add_func(bus=bus, **comp_data) + return comp_name def _connect_mv_bus_to_target_object( diff --git a/edisgo/tools/geo.py b/edisgo/tools/geo.py index 79b880a6b..f6541a563 100755 --- a/edisgo/tools/geo.py +++ b/edisgo/tools/geo.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING +import pandas as pd + from geopy.distance import geodesic from pyproj import Transformer @@ -212,12 +214,9 @@ def find_nearest_bus(point, bus_target): Tuple that contains the name of the nearest bus and its distance in km. """ - bus_target["dist"] = [ - geodesic((point.y, point.x), (y, x)).km - for (x, y) in zip(bus_target["x"], bus_target["y"]) - ] + bus_target = calculate_distance_to_buses_df(point, bus_target) - return bus_target["dist"].idxmin(), bus_target["dist"].min() + return bus_target["distance"].idxmin(), bus_target["distance"].min() def find_nearest_conn_objects(grid_topology, bus, lines, conn_diff_tolerance=0.0001): @@ -324,3 +323,33 @@ def mv_grid_gdf(edisgo_obj: EDisGo): geometry=[edisgo_obj.topology.grid_district["geom"]], crs=f"EPSG:{edisgo_obj.topology.grid_district['srid']}", ) + + +def calculate_distance_to_buses_df( + point: Point, buses_df: pd.DataFrame +) -> pd.DataFrame: + """ + Calculate the distance between buses and a given geometry. + + Parameters + ---------- + point : :shapely:`shapely.Point` + Geolocation to calculate distance to. + buses_df : :pandas:`pandas.DataFrame` + Dataframe with buses and their positions given in 'x' and 'y' + columns. The dataframe has the same format as + :attr:`~.network.topology.Topology.buses_df`. + + Returns + ------- + :pandas:`pandas.DataFrame` + Data of `buses_df` with additional column 'distance' containing the distance + to the given geometry in km. + + """ + distances = buses_df.apply( + lambda row: geodesic((row["y"], row["x"]), (point.y, point.x)).km, + axis=1, + ) + buses_df.loc[:, "distance"] = distances + return buses_df diff --git a/tests/network/test_topology.py b/tests/network/test_topology.py index 1a9023606..140d4b3c0 100644 --- a/tests/network/test_topology.py +++ b/tests/network/test_topology.py @@ -945,14 +945,29 @@ class TestTopologyWithEdisgoObject: """ - @pytest.yield_fixture(autouse=True) + @pytest.fixture(autouse=True) def setup_class(self): self.edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) self.edisgo.set_time_series_worst_case_analysis() + self.edisgo3 = EDisGo( + ding0_grid=pytest.ding0_test_network_3_path, legacy_ding0_grids=False + ) + self.edisgo3.set_time_series_worst_case_analysis() def test_to_geopandas(self): # further tests of to_geopandas are conducted in test_geopandas_helper.py + # 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}, + } + # further tests of to_geopandas are conducted in test_geopandas_helper.py + # set up edisgo object with georeferenced LV edisgo_geo = EDisGo( ding0_grid=pytest.ding0_test_network_3_path, legacy_ding0_grids=False @@ -972,6 +987,20 @@ def test_to_geopandas(self): "transformers_gdf", ] + 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(geopandas_container, GeoPandasGridContainer) + + # 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")) for test_suit, params in test_suits.items(): # call to_geopandas() function with different settings geopandas_container = params["edisgo_obj"].topology.to_geopandas( @@ -989,6 +1018,7 @@ def test_to_geopandas(self): assert isinstance(attr, GeoDataFrame) + common_cols = list(set(attr.columns).intersection(grid_attr.columns)) common_cols = list(set(attr.columns).intersection(grid_attr.columns)) assert_frame_equal( @@ -1950,3 +1980,184 @@ def test_find_meshes(self, caplog: pytest.LogCaptureFixture): assert len(meshes) == 2 assert "Bus_BranchTee_LVGrid_2_3" in meshes[1] assert "Grid contains mesh(es)." in caplog.text + + # Parametrize the test function + @pytest.mark.parametrize("sector", ["home", "work"]) + @pytest.mark.parametrize( + "comp_type", ["charging_point", "heat_pump", "storage_unit", "generator"] + ) + @pytest.mark.parametrize("voltage_level", [6, 7]) + @pytest.mark.parametrize("max_distance_from_target_bus", [0.01]) + @pytest.mark.parametrize("allowed_number_of_comp_per_bus", [2]) + @pytest.mark.parametrize("allow_mv_connection", [True, False]) + def test_connect_to_lv_based_on_geolocation_parametrized( + self, + sector, + comp_type, + voltage_level, + max_distance_from_target_bus, + allowed_number_of_comp_per_bus, + allow_mv_connection, + ): + x = self.edisgo3.topology.buses_df.at["Busbar_mvgd_33535_MV", "x"] + y = self.edisgo3.topology.buses_df.at["Busbar_mvgd_33535_MV", "y"] + test_cp = { + "p_set": 0.01, + "geom": Point((x, y)), + "sector": sector, + "voltage_level": voltage_level, + "mvlv_subst_id": 3.0, + } + if comp_type == "generator": + test_cp["generator_type"] = "solar" + test_cp["generator_id"] = 23456 + + if ( + comp_type == "charging_point" + and sector == "home" + and voltage_level == 7 + and not allow_mv_connection + and max_distance_from_target_bus == 0.01 + and allowed_number_of_comp_per_bus == 2 + ): + for _ in range(5): + test_cp = { + "p_set": 0.01, + "geom": Point((x, y)), + "sector": sector, + "voltage_level": voltage_level, + "mvlv_subst_id": 3.0, + } + comp_name = self.edisgo3.topology.connect_to_lv_based_on_geolocation( + edisgo_object=self.edisgo3, + comp_data=test_cp, + comp_type=comp_type, + max_distance_from_target_bus=max_distance_from_target_bus, + allowed_number_of_comp_per_bus=allowed_number_of_comp_per_bus, + allow_mv_connection=allow_mv_connection, + ) + assert comp_name.startswith("Charging_Point_") + assert len(self.edisgo3.topology.charging_points_df) == 5 + assert len(self.edisgo3.topology.charging_points_df.bus.unique()) == 3 + + else: + comp_name = self.edisgo3.topology.connect_to_lv_based_on_geolocation( + edisgo_object=self.edisgo3, + comp_data=test_cp, + comp_type=comp_type, + max_distance_from_target_bus=max_distance_from_target_bus, + allowed_number_of_comp_per_bus=allowed_number_of_comp_per_bus, + allow_mv_connection=allow_mv_connection, + ) + if comp_type == "charging_point": + assert comp_name.startswith("Charging_Point_") + elif comp_type == "heat_pump": + assert comp_name.startswith("Heat_Pump_") + elif comp_type == "storage_unit": + assert comp_name.startswith("StorageUnit") + else: + assert comp_name.startswith("Generator_") + + # Separate test cases for specific combinations + @pytest.mark.parametrize( + "sector, comp_type, voltage_level", + [ + ("public", "charging_point", 8), + ("public", "failing_test", 6), + ("public", "failing_test", 8), + ], + ) + def test_connect_to_lv_based_on_geolocation_value_error_parametrized( + self, sector, comp_type, voltage_level + ): + x = self.edisgo3.topology.buses_df.at["Busbar_mvgd_33535_MV", "x"] + y = self.edisgo3.topology.buses_df.at["Busbar_mvgd_33535_MV", "y"] + test_cp = { + "p_set": 0.01, + "geom": Point((x, y)), + "sector": sector, + "voltage_level": voltage_level, + "mvlv_subst_id": 3.0, + } + with pytest.raises(ValueError): + self.edisgo3.topology.connect_to_lv_based_on_geolocation( + edisgo_object=self.edisgo3, + comp_data=test_cp, + comp_type=comp_type, + max_distance_from_target_bus=0.01, + allowed_number_of_comp_per_bus=2, + allow_mv_connection=False, + ) + + # Define the parameters + comp_types = ["charging_point", "heat_pump", "storage_unit", "generator"] + + @pytest.mark.parametrize("comp_type", comp_types) + def test_connect_to_lv_based_on_geolocation(self, comp_type): + x = self.edisgo3.topology.buses_df.at[ + "BranchTee_mvgd_33535_lvgd_1150640000_building_430863", "x" + ] + y = self.edisgo3.topology.buses_df.at[ + "BranchTee_mvgd_33535_lvgd_1150640000_building_430863", "y" + ] + test_cp = { + "p_set": 0.01, + "geom": Point((x, y)), + "sector": "home", + "voltage_level": 7, + "mvlv_subst_id": 3.0, + } + if comp_type == "generator": + test_cp["generator_type"] = "solar" + test_cp["generator_id"] = 23456 + comp_name = self.edisgo3.topology.connect_to_lv_based_on_geolocation( + edisgo_object=self.edisgo3, + comp_data=test_cp, + comp_type=comp_type, + max_distance_from_target_bus=0.01, + allowed_number_of_comp_per_bus=2, + allow_mv_connection=False, + ) + + if comp_type == "charging_point": + assert comp_name.startswith("Charging_Point_") + assert ( + self.edisgo3.topology.charging_points_df.at[comp_name, "bus"] + == "BranchTee_mvgd_33535_lvgd_1150640000_building_430863" + ) + assert ( + self.edisgo3.topology.charging_points_df.at[comp_name, "p_set"] == 0.01 + ) + assert ( + self.edisgo3.topology.charging_points_df.at[comp_name, "sector"] + == "home" + ) + elif comp_type == "heat_pump": + assert comp_name.startswith("Heat_Pump_") + assert ( + self.edisgo3.topology.loads_df.at[comp_name, "bus"] + == "BranchTee_mvgd_33535_lvgd_1150640000_building_430863" + ) + assert self.edisgo3.topology.loads_df.at[comp_name, "p_set"] == 0.01 + assert self.edisgo3.topology.loads_df.at[comp_name, "type"] == "heat_pump" + assert self.edisgo3.topology.loads_df.at[comp_name, "sector"] == "home" + elif comp_type == "storage_unit": + assert comp_name.startswith("StorageUnit") + assert ( + self.edisgo3.topology.storage_units_df.at[comp_name, "bus"] + == "BranchTee_mvgd_33535_lvgd_1150640000_building_430863" + ) + assert ( + self.edisgo3.topology.storage_units_df.at[comp_name, "control"] == "PQ" + ) + assert self.edisgo3.topology.storage_units_df.at[comp_name, "p_nom"] == 0.01 + assert self.edisgo3.topology.storage_units_df.at[comp_name, "p_set"] == 0.01 + else: + assert comp_name.startswith("Generator_") + assert ( + self.edisgo3.topology.generators_df.at[comp_name, "bus"] + == "BranchTee_mvgd_33535_lvgd_1150640000_building_430863" + ) + assert self.edisgo3.topology.generators_df.at[comp_name, "type"] == "solar" + assert self.edisgo3.topology.generators_df.at[comp_name, "p_nom"] == 0.01 + assert self.edisgo3.topology.generators_df.at[comp_name, "control"] == "PQ" diff --git a/tests/tools/test_geo.py b/tests/tools/test_geo.py new file mode 100644 index 000000000..a6c7229c6 --- /dev/null +++ b/tests/tools/test_geo.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest + +from shapely.geometry import Point + +from edisgo import EDisGo +from edisgo.tools import geo + + +class TestTools: + @classmethod + def setup_class(self): + self.edisgo = EDisGo( + ding0_grid=pytest.ding0_test_network_3_path, legacy_ding0_grids=False + ) + + def test_find_nearest_bus(self): + # test with coordinates of existing bus + bus = self.edisgo.topology.buses_df.index[5] + point = Point( + ( + self.edisgo.topology.buses_df.at[bus, "x"], + self.edisgo.topology.buses_df.at[bus, "y"], + ) + ) + nearest_bus, dist = geo.find_nearest_bus(point, self.edisgo.topology.buses_df) + assert nearest_bus == bus + assert dist == 0.0 + + # test with random coordinates + point = Point((10.002736, 47.5426)) + nearest_bus, dist = geo.find_nearest_bus(point, self.edisgo.topology.buses_df) + assert nearest_bus == "BranchTee_mvgd_33535_lvgd_1163360000_building_431698" + assert np.isclose(dist, 0.000806993475812168, atol=1e-6)