def _calculate_mw_miles(original_grid, ct, exclude_branches=None): """Given a base grid and a change table, calculate the number of upgraded lines and transformers, and the total upgrade quantity (in MW & MW-miles). This function is separate from calculate_mw_miles() for testing purposes. Currently only supports change_tables that specify branches, not zone_name. Currently lumps Transformer and TransformerWinding upgrades together. :param powersimdata.input.grid.Grid original_grid: grid instance. :param dict ct: change table instance. :param list/tuple/set/None exclude_branches: branches to exclude. :raises ValueError: if not all values in exclude_branches are in the grid. :raises TypeError: if exclude_branches gets the wrong type. :return: (*dict*) -- Upgrades to the branches. """ upgrade_categories = ("mw_miles", "transformer_mw", "num_lines", "num_transformers") upgrades = {u: 0 for u in upgrade_categories} if "branch" not in ct or "branch_id" not in ct["branch"]: return upgrades if exclude_branches is None: exclude_branches = {} elif isinstance(exclude_branches, (list, set, tuple)): good_branch_indices = original_grid.branch.index if not all([e in good_branch_indices for e in exclude_branches]): raise ValueError("not all branches are present in grid!") exclude_branches = set(exclude_branches) else: raise TypeError("exclude_branches must be None, list, tuple, or set") base_branch = original_grid.branch upgraded_branches = ct["branch"]["branch_id"] for b, v in upgraded_branches.items(): if b in exclude_branches: continue # 'upgraded' capacity is v-1 because a scale of 1 = an upgrade of 0 upgraded_capacity = base_branch.loc[b, "rateA"] * (v - 1) device_type = base_branch.loc[b, "branch_device_type"] if device_type == "Line": from_coords = ( base_branch.loc[b, "from_lat"], base_branch.loc[b, "from_lon"], ) to_coords = (base_branch.loc[b, "to_lat"], base_branch.loc[b, "to_lon"]) addtl_mw_miles = upgraded_capacity * haversine(from_coords, to_coords) upgrades["mw_miles"] += addtl_mw_miles upgrades["num_lines"] += 1 elif device_type == "Transformer": upgrades["transformer_mw"] += upgraded_capacity upgrades["num_transformers"] += 1 elif device_type == "TransformerWinding": upgrades["transformer_mw"] += upgraded_capacity upgrades["num_transformers"] += 1 else: raise Exception("Unknown branch: " + str(b)) return upgrades
def _calculate_single_line_cost(line, bus): """Calculate cost of upgrading a single HVDC line. :param pandas.Series line: HVDC line series featuring *'from_bus_id'*', *'to_bus_id'* and *'Pmax'*. :param pandas.Dataframe bus: bus data frame featuring *'lat'*, *'lon'*. :return: (*float*) -- HVDC line upgrade cost in $2015. """ # Calculate distance from_lat = bus.loc[line.from_bus_id, "lat"] from_lon = bus.loc[line.from_bus_id, "lon"] to_lat = bus.loc[line.to_bus_id, "lat"] to_lon = bus.loc[line.to_bus_id, "lon"] miles = haversine((from_lat, from_lon), (to_lat, to_lon)) # Calculate cost total_cost = line.Pmax * ( miles * const.hvdc_line_cost["costMWmi"] * calculate_inflation(2015) + 2 * const.hvdc_terminal_cost_per_MW * calculate_inflation(2020) ) return total_cost
def _add_branch(self): """Adds branch(es) to the grid.""" v2x = voltage_to_x_per_distance(self.grid) for entry in self.ct["new_branch"]: new_branch = {c: 0 for c in self.grid.branch.columns} from_bus_id = entry["from_bus_id"] to_bus_id = entry["to_bus_id"] interconnect = self.grid.bus.loc[from_bus_id].interconnect from_zone_id = self.grid.bus.loc[from_bus_id].zone_id to_zone_id = self.grid.bus.loc[to_bus_id].zone_id from_zone_name = self.grid.id2zone[from_zone_id] to_zone_name = self.grid.id2zone[to_zone_id] from_lon = self.grid.bus.loc[from_bus_id].lon from_lat = self.grid.bus.loc[from_bus_id].lat to_lon = self.grid.bus.loc[to_bus_id].lon to_lat = self.grid.bus.loc[to_bus_id].lat from_basekv = v2x[self.grid.bus.loc[from_bus_id].baseKV] to_basekv = v2x[self.grid.bus.loc[to_bus_id].baseKV] distance = haversine((from_lat, from_lon), (to_lat, to_lon)) x = distance * np.mean([from_basekv, to_basekv]) new_branch["from_bus_id"] = entry["from_bus_id"] new_branch["to_bus_id"] = entry["to_bus_id"] new_branch["status"] = 1 new_branch["ratio"] = 0 new_branch["branch_device_type"] = "Line" new_branch["rateA"] = entry["Pmax"] new_branch["interconnect"] = interconnect new_branch["from_zone_id"] = from_zone_id new_branch["to_zone_id"] = to_zone_id new_branch["from_zone_name"] = from_zone_name new_branch["to_zone_name"] = to_zone_name new_branch["from_lon"] = from_lon new_branch["from_lat"] = from_lat new_branch["to_lon"] = to_lon new_branch["to_lat"] = to_lat new_branch["x"] = x new_index = [self.grid.branch.index[-1] + 1] self.grid.branch = pd.concat( [self.grid.branch, pd.DataFrame(new_branch, index=new_index)])
def _calculate_single_line_cost(line, bus): """Given a series representing a DC line upgrade/addition, and a dataframe of bus locations, calculate this line's upgrade cost. :param pandas.Series line: DC line series featuring: {"from_bus_id", "to_bus_id", "Pmax"}. :param pandas.Dataframe bus: Bus data frame featuring {"lat", "lon"}. :return: (*float*) -- DC line upgrade cost (in $2015). """ # Calculate distance from_lat = bus.loc[line.from_bus_id, "lat"] from_lon = bus.loc[line.from_bus_id, "lon"] to_lat = bus.loc[line.to_bus_id, "lat"] to_lon = bus.loc[line.to_bus_id, "lon"] miles = haversine((from_lat, from_lon), (to_lat, to_lon)) # Calculate cost total_cost = line.Pmax * ( miles * const.hvdc_line_cost["costMWmi"] * calculate_inflation(2015) + 2 * const.hvdc_terminal_cost_per_MW * calculate_inflation(2020)) return total_cost
def voltage_to_x_per_distance(grid): """Calculates reactance per distance for voltage level. :param powersimdata.input.grid.Grid grid: a Grid object instance. :return: (*dict*) -- bus voltage to average reactance per mile. """ branch = grid.branch[grid.branch.branch_device_type == "Line"] distance = (branch[["from_lat", "from_lon", "to_lat", "to_lon"]].apply(lambda x: haversine((x[0], x[1]), (x[2], x[3])), axis=1).values) no_zero = np.nonzero(distance)[0] x_per_distance = (branch.iloc[no_zero].x / distance[no_zero]).values basekv = np.array( [grid.bus.baseKV[i] for i in branch.iloc[no_zero].from_bus_id]) v2x = { v: np.mean(x_per_distance[np.where(basekv == v)[0]]) for v in set(basekv) } return v2x
def plot_powerflow_snapshot( scenario, hour, b2b_dclines=None, demand_centers=None, ac_branch_color="#8B36FF", dc_branch_color="#01D4ED", solar_color="#FFBB45", wind_color="#78D911", demand_color="gray", figsize=(1400, 800), circle_scale_factor=0.25, bg_width_scale_factor=0.001, pf_width_scale_factor=0.00125, arrow_pf_threshold=3000, arrow_dist_threshold=20, num_ac_arrows=1, num_dc_arrows=1, min_arrow_size=5, branch_alpha=0.5, state_borders_kwargs=None, x_range=None, y_range=None, legend_font_size=None, ): """Plot a snapshot of powerflow. :param powersimdata.scenario.scenario.Scenario scenario: scenario to plot. :param pandas.Timestamp/numpy.datetime64/datetime.datetime hour: snapshot interval. :param dict b2b_dclines: which DC lines are actually B2B facilities. Keys are: {"from", "to"}, values are iterables of DC line indices to plot (indices in "from" get plotted at the "from" end, and vice versa). :param pandas.DataFrame demand_centers: lat/lon centers at which to plot the demand from each load zone. :param str ac_branch_color: color to plot AC branches. :param str dc_branch_color: color to plot DC branches. :param str solar_color: color to plot solar generation. :param str wind_color: color to plot wind generation. :param str demand_color: color to plot demand. :param tuple figsize: size of the bokeh figure (in pixels). :param int/float circle_scale_factor: scale factor for demand/solar/wind circles. :param int/float bg_width_scale_factor: scale factor for grid capacities. :param int/float pf_width_scale_factor: scale factor for power flows. :param int/float arrow_pf_threshold: minimum power flow (MW) for adding arrows. :param int/float arrow_dist_threshold: minimum distance (miles) for adding arrows. :param int num_ac_arrows: number of arrows for each AC branch. :param int num_dc_arrows: number of arrows for each DC branch. :param int/float min_arrow_size: minimum arrow size. :param int/float branch_alpha: opaqueness of branches. :param dict state_borders_kwargs: keyword arguments to be passed to :func:`postreise.plot.plot_states.add_state_borders`. :param tuple(float, float) x_range: x range to zoom plot to (EPSG:3857). :param tuple(float, float) y_range: y range to zoom plot to (EPSG:3857). :param int/str legend_font_size: size to display legend specified as e.g. 12/'12pt'. :return: (*bokeh.plotting.figure*) -- power flow snapshot map. """ _check_scenario_is_in_analyze_state(scenario) _check_date_range_in_scenario(scenario, hour, hour) # Get scenario data grid = scenario.state.get_grid() bus = grid.bus plant = grid.plant # Augment the branch dataframe with extra info needed for plotting branch = grid.branch branch["pf"] = scenario.state.get_pf().loc[hour] branch = branch.query("pf != 0").copy() branch["dist"] = branch.apply(lambda x: haversine((x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1) branch["arrow_size"] = branch["pf"].abs( ) * pf_width_scale_factor + min_arrow_size branch = project_branch(branch) # Augment the dcline dataframe with extra info needed for plotting dcline = grid.dcline dcline["pf"] = scenario.state.get_dcline_pf().loc[hour] dcline["from_lat"] = dcline.apply(lambda x: bus.loc[x.from_bus_id, "lat"], axis=1) dcline["from_lon"] = dcline.apply(lambda x: bus.loc[x.from_bus_id, "lon"], axis=1) dcline["to_lat"] = dcline.apply(lambda x: bus.loc[x.to_bus_id, "lat"], axis=1) dcline["to_lon"] = dcline.apply(lambda x: bus.loc[x.to_bus_id, "lon"], axis=1) dcline["dist"] = dcline.apply(lambda x: haversine((x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1) dcline["arrow_size"] = dcline["pf"].abs( ) * pf_width_scale_factor + min_arrow_size dcline = project_branch(dcline) # Create a dataframe for demand plotting, if necessary if demand_centers is not None: demand = scenario.state.get_demand() demand_centers["demand"] = demand.loc[hour] demand_centers = project_bus(demand_centers) # create canvas canvas = create_map_canvas(figsize=figsize, x_range=x_range, y_range=y_range) # Add state borders default_state_borders_kwargs = {"fill_alpha": 0.0, "background_map": False} all_state_borders_kwargs = ({ **default_state_borders_kwargs, **state_borders_kwargs } if state_borders_kwargs is not None else default_state_borders_kwargs) _check_func_kwargs(add_state_borders, set(all_state_borders_kwargs), "state_borders_kwargs") canvas = add_state_borders(canvas, **all_state_borders_kwargs) if b2b_dclines is not None: # Append the pseudo AC lines to the branch dataframe, remove from dcline all_b2b_dclines = list(b2b_dclines["to"]) + list(b2b_dclines["from"]) pseudo_ac_lines = dcline.loc[all_b2b_dclines] pseudo_ac_lines["rateA"] = pseudo_ac_lines[["Pmin", "Pmax"]].abs().max(axis=1) branch = branch.append(pseudo_ac_lines) # Construct b2b dataframe so that all get plotted at their 'from' x/y b2b_from = dcline.loc[b2b_dclines["from"]] b2b_to = dcline.loc[b2b_dclines["to"]].rename( { "from_x": "to_x", "from_y": "to_y", "to_x": "from_x", "to_y": "from_y" }, axis=1, ) b2b = pd.concat([b2b_from, b2b_to]) dcline = dcline.loc[~dcline.index.isin(all_b2b_dclines)] # Plot grid background in grey canvas.multi_line( branch[["from_x", "to_x"]].to_numpy().tolist(), branch[["from_y", "to_y"]].to_numpy().tolist(), color="gray", alpha=branch_alpha, line_width=(branch["rateA"].abs() * bg_width_scale_factor), ) canvas.multi_line( dcline[["from_x", "to_x"]].to_numpy().tolist(), dcline[["from_y", "to_y"]].to_numpy().tolist(), color="gray", alpha=branch_alpha, line_width=(dcline[["Pmin", "Pmax"]].abs().max(axis=1) * bg_width_scale_factor), ) if b2b_dclines is not None: canvas.scatter( x=b2b.from_x, y=b2b.from_y, color="gray", alpha=0.5, marker="triangle", size=(b2b[["Pmin", "Pmax"]].abs().max(axis=1) * bg_width_scale_factor), ) fake_location = branch.iloc[0].drop("x").rename({ "from_x": "x", "from_y": "y" }) # Legend entries canvas.multi_line( (fake_location.x, fake_location.x), (fake_location.y, fake_location.y), color=dc_branch_color, alpha=branch_alpha, line_width=10, legend_label="HVDC powerflow", visible=False, ) canvas.multi_line( (fake_location.x, fake_location.x), (fake_location.y, fake_location.y), color=ac_branch_color, alpha=branch_alpha, line_width=10, legend_label="AC powerflow", visible=False, ) canvas.circle( fake_location.x, fake_location.y, color=solar_color, alpha=0.6, size=5, legend_label="Solar Gen.", visible=False, ) canvas.circle( fake_location.x, fake_location.y, color=wind_color, alpha=0.6, size=5, legend_label="Wind Gen.", visible=False, ) # Plot demand if demand_centers is not None: canvas.circle( fake_location.x, fake_location.y, color=demand_color, alpha=0.3, size=5, legend_label="Demand", visible=False, ) canvas.circle( demand_centers.x, demand_centers.y, color=demand_color, alpha=0.3, size=(demand_centers.demand * circle_scale_factor)**0.5, ) # Aggregate solar and wind for plotting plant_with_pg = plant.copy() plant_with_pg["pg"] = scenario.state.get_pg().loc[hour] grouped_solar = aggregate_plant_generation( plant_with_pg.query("type == 'solar'")) grouped_wind = aggregate_plant_generation( plant_with_pg.query("type == 'wind'")) # Plot solar, wind canvas.circle( grouped_solar.x, grouped_solar.y, color=solar_color, alpha=0.6, size=(grouped_solar.pg * circle_scale_factor)**0.5, ) canvas.circle( grouped_wind.x, grouped_wind.y, color=wind_color, alpha=0.6, size=(grouped_wind.pg * circle_scale_factor)**0.5, ) # Plot powerflow on AC branches canvas.multi_line( branch[["from_x", "to_x"]].to_numpy().tolist(), branch[["from_y", "to_y"]].to_numpy().tolist(), color=ac_branch_color, alpha=branch_alpha, line_width=(branch["pf"].abs() * pf_width_scale_factor), ) add_arrows( canvas, branch, color=ac_branch_color, pf_threshold=arrow_pf_threshold, dist_threshold=arrow_dist_threshold, n=num_ac_arrows, ) # Plot powerflow on DC lines canvas.multi_line( dcline[["from_x", "to_x"]].to_numpy().tolist(), dcline[["from_y", "to_y"]].to_numpy().tolist(), color=dc_branch_color, alpha=branch_alpha, line_width=(dcline["pf"].abs() * pf_width_scale_factor), ) add_arrows( canvas, dcline, color=dc_branch_color, pf_threshold=0, dist_threshold=0, n=num_dc_arrows, ) # B2Bs if b2b_dclines is not None: canvas.scatter( x=b2b.from_x, y=b2b.from_y, color=dc_branch_color, alpha=0.5, marker="triangle", size=(b2b["pf"].abs() * pf_width_scale_factor * 5), ) canvas.legend.location = "bottom_left" if legend_font_size is not None: if isinstance(legend_font_size, (int, float)): legend_font_size = f"{legend_font_size}pt" canvas.legend.label_text_font_size = legend_font_size return canvas
def add_transmission_upgrades( canvas, branch_merge, dc_merge, b2b_indices=None, diff_threshold=100, all_branch_scale=1, diff_branch_scale=1, all_branch_min=0.1, diff_branch_min=1.0, b2b_scale=5, dcline_upgrade_dist_threshold=0, ): """Make map of branches showing upgrades. :param bokeh.plotting.figure.Figure canvas: canvas to add upgrades to. :param pandas.DataFrame branch_merge: branch of scenarios 1 and 2 :param pandas.DataFrame dc_merge: dclines for scenarios 1 and 2 :param list/set/tuple b2b_indices: indices of HVDC lines which are back-to-backs. :param int/float diff_threshold: difference threshold (in MW), above which branches are highlighted. :param int/float all_branch_scale: scale factor for plotting all branches (pixels/GW). :param int/float diff_branch_scale: scale factor for plotting branches with differences above the threshold (pixels/GW). :param int/float all_branch_min: minimum width to plot all branches. :param int/float diff_branch_min: minimum width to plot branches with significant differences. :param int/float b2b_scale: scale factor for plotting b2b facilities (pixels/GW). :param int/float dcline_upgrade_dist_threshold: minimum distance (miles) for plotting DC line upgrades (if none are longer, no legend entry will be created). :return: (*bokeh.plotting.figure.Figure*) -- Bokeh map plot of color-coded upgrades. """ # plotting constants legend_alpha = 0.9 all_elements_alpha = 0.5 differences_alpha = 0.8 # convert scale factors from pixels/GW to pixels/MW (base units for our grid data) all_branch_scale_MW = all_branch_scale / 1000 # noqa: N806 diff_branch_scale_MW = diff_branch_scale / 1000 # noqa: N806 b2b_scale_MW = b2b_scale / 1000 # noqa: N806 # data prep branch_all = project_branch(branch_merge) branch_dc = project_branch(dc_merge) # For these, we will plot a triangle for the B2B location, plus 'pseudo' AC lines # get_level_values allows us to index into MultiIndex as necessary b2b_indices = {} if b2b_indices is None else b2b_indices b2b_mask = branch_dc.index.get_level_values(0).isin(b2b_indices) # .copy() avoids a pandas SettingWithCopyError later b2b = branch_dc.iloc[b2b_mask].copy() branch_dc_lines = branch_dc.loc[~b2b_mask].copy() # Color branches based on upgraded capacity branch_all["color"] = np.nan branch_all.loc[branch_all["diff"] > diff_threshold, "color"] = colors.be_blue branch_all.loc[branch_all["diff"] < -1 * diff_threshold, "color"] = colors.be_purple # Color pseudo AC branches based on upgraded capacity b2b["color"] = np.nan b2b.loc[b2b["diff"] > diff_threshold, "color"] = colors.be_blue b2b.loc[b2b["diff"] < -1 * diff_threshold, "color"] = colors.be_purple b2b = b2b[~b2b.color.isnull()] # Color DC lines based on upgraded capacity branch_dc_lines["dist"] = branch_dc_lines.apply(lambda x: haversine( (x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1) branch_dc_lines = branch_dc_lines.loc[ branch_dc_lines.dist >= dcline_upgrade_dist_threshold] branch_dc_lines.loc[:, "color"] = np.nan branch_dc_lines.loc[branch_dc_lines["diff"] > 0, "color"] = colors.be_green branch_dc_lines.loc[branch_dc_lines["diff"] < 0, "color"] = colors.be_lightblue # Create ColumnDataSources for bokeh to plot with source_all_ac = ColumnDataSource({ "xs": branch_all[["from_x", "to_x"]].values.tolist(), "ys": branch_all[["from_y", "to_y"]].values.tolist(), "cap": branch_all["rateA"] * all_branch_scale_MW + all_branch_min, "color": branch_all["color"], }) # AC branches with significant differences ac_diff_branches = branch_all.loc[~branch_all.color.isnull()] source_ac_difference = ColumnDataSource({ "xs": ac_diff_branches[["from_x", "to_x"]].values.tolist(), "ys": ac_diff_branches[["from_y", "to_y"]].values.tolist(), "diff": (ac_diff_branches["diff"].abs() * diff_branch_scale_MW + diff_branch_min), "color": ac_diff_branches["color"], }) source_all_dc = ColumnDataSource({ "xs": branch_dc_lines[["from_x", "to_x"]].values.tolist(), "ys": branch_dc_lines[["from_y", "to_y"]].values.tolist(), "cap": branch_dc_lines.Pmax * all_branch_scale_MW + all_branch_min, "color": branch_dc_lines["color"], }) dc_diff_lines = branch_dc_lines.loc[~branch_dc_lines.color.isnull()] source_dc_differences = ColumnDataSource({ "xs": dc_diff_lines[["from_x", "to_x"]].values.tolist(), "ys": dc_diff_lines[["from_y", "to_y"]].values.tolist(), "diff": (dc_diff_lines["diff"].abs() * diff_branch_scale_MW + diff_branch_min), "color": dc_diff_lines["color"], }) source_pseudoac = ColumnDataSource( # pseudo ac scen 1 { "xs": b2b[["from_x", "to_x"]].values.tolist(), "ys": b2b[["from_y", "to_y"]].values.tolist(), "cap": b2b.Pmax * all_branch_scale_MW + all_branch_min, "diff": b2b["diff"].abs() * diff_branch_scale_MW + diff_branch_min, "color": b2b["color"], } ) # Build the legend leg_x = [-8.1e6] * 2 leg_y = [5.2e6] * 2 # These are 'dummy' series to populate the legend with if len(branch_dc_lines[branch_dc_lines["diff"] > 0]) > 0: canvas.multi_line( leg_x, leg_y, color=colors.be_green, alpha=legend_alpha, line_width=10, legend_label="Additional HVDC Capacity", ) if len(branch_dc_lines[branch_dc_lines["diff"] < 0]) > 0: canvas.multi_line( leg_x, leg_y, color=colors.be_lightblue, alpha=legend_alpha, line_width=10, legend_label="Reduced HVDC Capacity", ) if len(branch_all[branch_all["diff"] < 0]) > 0: canvas.multi_line( leg_x, leg_y, color=colors.be_purple, alpha=legend_alpha, line_width=10, legend_label="Reduced AC Transmission", ) if len(branch_all[branch_all["diff"] > 0]) > 0: canvas.multi_line( leg_x, leg_y, color=colors.be_blue, alpha=legend_alpha, line_width=10, legend_label="Upgraded AC transmission", ) if len(b2b[b2b["diff"] > 0]) > 0: canvas.scatter( x=b2b.from_x[1], y=b2b.from_y[1], color=colors.be_magenta, marker="triangle", legend_label="Upgraded B2B capacity", size=30, alpha=legend_alpha, ) # Everything below gets plotted into the 'main' figure background_plot_dicts = [ { "source": source_all_ac, "color": "gray", "line_width": "cap" }, { "source": source_all_dc, "color": "gray", "line_width": "cap" }, { "source": source_pseudoac, "color": "gray", "line_width": "cap" }, ] for d in background_plot_dicts: canvas.multi_line( "xs", "ys", color=d["color"], line_width=d["line_width"], source=d["source"], alpha=all_elements_alpha, ) # all B2Bs canvas.scatter( x=b2b.from_x, y=b2b.from_y, color="gray", marker="triangle", size=b2b["Pmax"].abs() * b2b_scale_MW, alpha=all_elements_alpha, ) difference_plot_dicts = [ { "source": source_pseudoac, "color": "color", "line_width": "diff" }, { "source": source_ac_difference, "color": "color", "line_width": "diff" }, { "source": source_dc_differences, "color": "color", "line_width": "diff" }, ] for d in difference_plot_dicts: canvas.multi_line( "xs", "ys", color=d["color"], line_width=d["line_width"], source=d["source"], alpha=differences_alpha, ) # B2Bs with differences canvas.scatter( x=b2b.from_x, y=b2b.from_y, color=colors.be_magenta, marker="triangle", size=b2b["diff"].abs() * b2b_scale_MW, ) return canvas
def sjoin_nearest(left_df, right_df, search_dist=0.06): """Perform a spatial join between two input layers. :param geopandas.GeoDataFrame left_df: A dataframe of Points. :param geopandas.GeoDataFrame right_df: A dataframe of Polygons/Multipolygons. :param float/int search_dist: radius (in map units) around point to detect polygons. :return: (*geopandas.GeoDataFrame*) -- data frame of Points mapped to each Polygon. .. note:: data from nearest Polygon/Multipolygon will be used as a result if a Point falls outside all available Polygon/Multipolygons. """ def _find_nearest(series, polygons, search_dist): """Find the closest polygon. :param pandas.Series series: point to map. :param geopandas.geodataframe.GeoDataFrame polygons: polygons to select from. :param float search_dist: radius around point to detect polygons. """ geom = series[left_df.geometry.name] # Get geometries within search distance candidates = polygons.loc[polygons.intersects( geom.buffer(search_dist))] if len(candidates) == 0: raise ValueError( f"No polygons found within {search_dist} of {series.name}") # Select the closest Polygon distances = candidates.apply( lambda x: geom.distance(x[candidates.geometry.name].exterior), axis=1) closest_poly = polygons.loc[distances.idxmin].to_frame().T # Reset index series = series.to_frame().T.reset_index(drop=True) # Drop geometry from closest polygon closest_poly = closest_poly.drop(polygons.geometry.name, axis=1) closest_poly = closest_poly.reset_index(drop=True) # Join values join = series.join(closest_poly, lsuffix="_left", rsuffix="_right") # Add information about distance to closest geometry if requested join["dist"] = distances.min() return join.squeeze() gpd = _check_import("geopandas") if "dist" in (set(left_df.columns) | set(right_df.columns)): raise ValueError( "neither series nor polygons can contain a 'dist' column") # Explode possible MultiGeometries. This is a major speedup! right_df = right_df.explode() right_df = right_df.reset_index(drop=True) # Make spatial join between points that fall inside the Polygons points_in_regions = gpd.sjoin(left_df=left_df, right_df=right_df, op="intersects") points_in_regions["dist"] = 0 # Since polygons may overlap, there can be duplicated buses that we want to filter duplicated = points_in_regions.loc[points_in_regions.index.duplicated( keep=False)] to_drop = set() for bus in set(duplicated["bus_id"]): entries = duplicated.query("bus_id == @bus") coords = entries["geometry"].iloc[0].coords[ 0] # First duped entry, only point regions = set(entries["name_abbr"]) # noqa: F841 candidates = points_in_regions.query( "index not in @duplicated.index and name_abbr in @regions") neighbor = candidates.apply(lambda x: haversine( (x.geometry.x, x.geometry.y), coords), axis=1).idxmin() closest_region = candidates.loc[neighbor, "name_abbr"] # noqa: F841 # There may be more than two overlapping geometries, capture all but the closest drop_regions = set( entries.query("name_abbr != @closest_region")["name_abbr"]) # Since indices are duplicated, we need to drop via two-column tuples to_drop |= {(bus, d) for d in drop_regions} points_in_regions = points_in_regions.loc[~points_in_regions.set_index( ["bus_id", "name_abbr"]).index.isin(to_drop)] # Find closest Polygons, for points that don't fall within any missing_indices = set(left_df.index) - set(points_in_regions.index) points_not_in_regions = left_df.loc[missing_indices] closest_geometries = points_not_in_regions.apply(_find_nearest, args=(right_df, search_dist), axis=1) # Merge everything together closest_geometries = gpd.GeoDataFrame(closest_geometries) result = points_in_regions.append(closest_geometries, ignore_index=True, sort=False) return result
def map_interconnections( grid, branch_distance_cutoff=5, figsize=(1400, 800), branch_width_scale_factor=0.5, hvdc_width_scale_factor=1, b2b_size_scale_factor=50, state_borders_kwargs=None, ): """Map transmission lines color coded by interconnection. :param powersimdata.input.grid.Grid grid: grid object. :param int/float branch_distance_cutoff: distance cutoff for branch display. :param tuple figsize: size of the bokeh figure (in pixels). :param int/float branch_width_scale_factor: scale factor for branch capacities. :param int/float hvdc_width_scale_factor: scale factor for hvdc capacities. :param int/float b2b_size_scale_factor: scale factor for back-to_back capacities. :param dict state_borders_kwargs: keyword arguments to be passed to :func:`postreise.plot.plot_states.add_state_borders`. :return: (*bokeh.plotting.figure*) -- interconnection map with lines and nodes. :raises TypeError: if ``branch_device_cutoff`` is not ``float``. if ``branch_width_scale_factor`` is not ``int`` or ``float``. if ``hvdc_width_scale_factor`` is not ``int`` or ``float``. if ``b2b_size_scale_factor`` is not ``int`` or ``float``. :raises ValueError: if ``branch_device_cutoff`` is negative. if ``branch_width_scale_factor`` is negative. if ``hvdc_width_scale_factor`` is negative. if ``b2b_size_scale_factor`` is negative. if grid model is not supported. """ _check_grid_type(grid) if not isinstance(branch_distance_cutoff, (int, float)): raise TypeError("branch_distance_cutoff must be an int") if branch_distance_cutoff <= 0: raise ValueError("branch_distance_cutoff must be strictly positive") if not isinstance(branch_width_scale_factor, (int, float)): raise TypeError("branch_width_scale_factor must be a int/float") if branch_width_scale_factor < 0: raise ValueError("branch_width_scale_factor must be positive") if not isinstance(hvdc_width_scale_factor, (int, float)): raise TypeError("hvdc_width_scale_factor must be a int/float") if hvdc_width_scale_factor < 0: raise ValueError("hvdc_width_scale_factor must be positive") if not isinstance(b2b_size_scale_factor, (int, float)): raise TypeError("b2b_size_scale_factor must be a int/float") if b2b_size_scale_factor < 0: raise ValueError("b2b_size_scale_factor must be positive") # branches branch = grid.branch.copy() branch["to_coord"] = list(zip(branch["to_lat"], branch["to_lon"])) branch["from_coord"] = list(zip(branch["from_lat"], branch["from_lon"])) branch["dist"] = branch.apply( lambda row: distance.haversine(row["to_coord"], row["from_coord"]), axis=1) branch = branch.loc[branch["dist"] > branch_distance_cutoff] branch = project_branch(branch) branch_west = branch.loc[branch["interconnect"] == "Western"] branch_east = branch.loc[branch["interconnect"] == "Eastern"] branch_tx = branch.loc[branch["interconnect"] == "Texas"] # HVDC lines all_dcline = grid.dcline.copy() all_dcline["from_lon"] = grid.bus.loc[all_dcline["from_bus_id"], "lon"].values all_dcline["from_lat"] = grid.bus.loc[all_dcline["from_bus_id"], "lat"].values all_dcline["to_lon"] = grid.bus.loc[all_dcline["to_bus_id"], "lon"].values all_dcline["to_lat"] = grid.bus.loc[all_dcline["to_bus_id"], "lat"].values all_dcline = project_branch(all_dcline) if grid.grid_model == "usa_tamu": b2b_id = range(9) else: raise ValueError("grid model is not supported") dcline = all_dcline.iloc[~all_dcline.index.isin(b2b_id)] b2b = all_dcline.iloc[b2b_id] # create canvas canvas = create_map_canvas(figsize=figsize) # add state borders default_state_borders_kwargs = { "line_width": 2, "fill_alpha": 0, "background_map": False, } all_state_borders_kwargs = ({ **default_state_borders_kwargs, **state_borders_kwargs } if state_borders_kwargs is not None else default_state_borders_kwargs) _check_func_kwargs(add_state_borders, set(all_state_borders_kwargs), "state_borders_kwargs") canvas = add_state_borders(canvas, **all_state_borders_kwargs) # add state tooltips state_counts = count_nodes_per_state(grid) state2label = { s: c for s, c in zip(state_counts.index, state_counts.to_numpy()) } canvas = add_state_tooltips(canvas, "nodes", state2label) canvas.multi_line( branch_west[["from_x", "to_x"]].to_numpy().tolist(), branch_west[["from_y", "to_y"]].to_numpy().tolist(), color="#006ff9", line_width=branch_west["rateA"].abs() * 1e-3 * branch_width_scale_factor, legend_label="Western", ) canvas.multi_line( branch_east[["from_x", "to_x"]].to_numpy().tolist(), branch_east[["from_y", "to_y"]].to_numpy().tolist(), color="#8B36FF", line_width=branch_east["rateA"].abs() * 1e-3 * branch_width_scale_factor, legend_label="Eastern", ) canvas.multi_line( branch_tx[["from_x", "to_x"]].to_numpy().tolist(), branch_tx[["from_y", "to_y"]].to_numpy().tolist(), color="#01D4ED", line_width=branch_tx["rateA"].abs() * 1e-3 * branch_width_scale_factor, legend_label="Texas", ) canvas.multi_line( dcline[["from_x", "to_x"]].to_numpy().tolist(), dcline[["from_y", "to_y"]].to_numpy().tolist(), color="#FF2370", line_width=dcline["Pmax"] * 1e-3 * hvdc_width_scale_factor, legend_label="HVDC", ) canvas.scatter( x=b2b["from_x"], y=b2b["from_y"], color="#FF2370", marker="triangle", size=b2b["Pmax"] * 1e-3 * b2b_size_scale_factor, legend_label="Back-to-Back", ) canvas.legend.location = "bottom_left" canvas.legend.label_text_font_size = "12pt" return canvas
def _calculate_ac_inv_costs(grid_new, sum_results=True): """Given a grid, calculate the total cost of building that grid's lines and transformers. This function is separate from calculate_ac_inv_costs() for testing purposes. Currently counts Transformer and TransformerWinding as transformers. Currently uses NEEM regions to find regional multipliers. :param powersimdata.input.grid.Grid grid_new: grid instance. :param boolean sum_results: if True, sum dataframe for each category. :return: (*dict*) -- Total costs (line costs, transformer costs). """ def select_mw(x, cost_df): """Given a single branch, determine the closest kV/MW combination and return the corresponding cost $/MW-mi. :param pandas.core.series.Series x: data for a single branch :param pandas.core.frame.DataFrame cost_df: DataFrame with kV, MW, cost columns :return: (*pandas.core.series.Series*) -- series of ['MW', 'costMWmi'] to be assigned to given branch """ # select corresponding cost table of selected kV tmp = cost_df[cost_df["kV"] == x.kV] # get rid of NaN values in this kV table tmp = tmp[~tmp["MW"].isna()] # find closest MW & corresponding cost return tmp.iloc[np.argmin(np.abs(tmp["MW"] - x.rateA))][["MW", "costMWmi"]] def get_transformer_mult(x, bus_reg, ac_reg_mult, xfmr_lookup_alerted=set()): """Determine the regional multiplier based on kV and power (closest). :param pandas.core.series.Series x: data for a single transformer. :param pandas.core.frame.DataFrame bus_reg: data frame with bus regions :param pandas.core.frame.DataFrame ac_reg_mult: data frame with regional mults. :param set xfmr_lookup_alerted: set of (voltage, region) tuples for which a message has already been printed that this lookup was not found. :return: (*float*) -- regional multiplier. """ max_kV = bus.loc[[x.from_bus_id, x.to_bus_id], "baseKV"].max() region = bus_reg.loc[x.from_bus_id, "name_abbr"] region_mults = ac_reg_mult.loc[ac_reg_mult.name_abbr == region] mult_lookup_kV = region_mults.loc[(region_mults.kV - max_kV).abs().idxmin()].kV region_kV_mults = region_mults[region_mults.kV == mult_lookup_kV] region_kV_mults = region_kV_mults.loc[~region_kV_mults.mult.isnull()] if len(region_kV_mults) == 0: mult = 1 if (mult_lookup_kV, region) not in xfmr_lookup_alerted: print( f"No multiplier for voltage {mult_lookup_kV} in {region}") xfmr_lookup_alerted.add((mult_lookup_kV, region)) else: mult_lookup_MW = region_kV_mults.loc[(region_kV_mults.MW - x.rateA).abs().idxmin(), "MW"] mult = (region_kV_mults.loc[region_kV_mults.MW == mult_lookup_MW].squeeze().mult) return mult # import data ac_cost = pd.DataFrame(const.ac_line_cost) ac_reg_mult = pd.read_csv(const.ac_reg_mult_path) try: bus_reg = pd.read_csv(const.bus_neem_regions_path, index_col="bus_id") except FileNotFoundError: bus_reg = bus_to_neem_reg(grid_new.bus) bus_reg.sort_index().to_csv(const.bus_neem_regions_path) xfmr_cost = pd.read_csv(const.transformer_cost_path, index_col=0).fillna(0) xfmr_cost.columns = [int(c) for c in xfmr_cost.columns] # Mirror across diagonal xfmr_cost += xfmr_cost.to_numpy().T - np.diag(np.diag( xfmr_cost.to_numpy())) # map line kV bus = grid_new.bus branch = grid_new.branch branch.loc[:, "kV"] = branch.apply(lambda x: bus.loc[x.from_bus_id, "baseKV"], axis=1) # separate transformers and lines t_mask = branch["branch_device_type"].isin( ["Transformer", "TransformerWinding"]) transformers = branch[t_mask].copy() lines = branch[~t_mask].copy() # Find closest kV rating lines.loc[:, "kV"] = lines.apply( lambda x: ac_cost.loc[(ac_cost["kV"] - x.kV).abs().idxmin(), "kV"], axis=1, ) lines[["MW", "costMWmi"]] = lines.apply(lambda x: select_mw(x, ac_cost), axis=1) # check that all buses included in this file and lat/long values match, # otherwise re-run mapping script on mis-matching buses. # these buses are missing in region file bus_fix_index = bus[~bus.index.isin(bus_reg.index)].index bus_mask = bus[~bus.index.isin(bus_fix_index)] bus_mask = bus_mask.merge(bus_reg, how="left", on="bus_id") # these buses have incorrect lat/lon values in the region mapping file. # re-running the region mapping script on those buses only. bus_fix_index2 = bus_mask[ ~np.isclose(bus_mask.lat_x, bus_mask.lat_y) | ~np.isclose(bus_mask.lon_x, bus_mask.lon_y)].index bus_fix_index_all = bus_fix_index.tolist() + bus_fix_index2.tolist() # fix the identified buses, if necessary if len(bus_fix_index_all) > 0: bus_fix = bus_to_neem_reg(bus[bus.index.isin(bus_fix_index_all)]) fix_cols = ["name_abbr", "lat", "lon"] bus_reg.loc[bus_reg.index.isin(bus_fix.index), fix_cols] = bus_fix[fix_cols] bus_reg.drop(["lat", "lon"], axis=1, inplace=True) # map region multipliers onto lines ac_reg_mult = ac_reg_mult.melt(id_vars=["kV", "MW"], var_name="name_abbr", value_name="mult") lines = lines.merge(bus_reg, left_on="to_bus_id", right_on="bus_id", how="inner") lines = lines.merge(ac_reg_mult, on=["name_abbr", "kV", "MW"], how="left") lines.rename(columns={ "name_abbr": "reg_to", "mult": "mult_to" }, inplace=True) lines = lines.merge(bus_reg, left_on="from_bus_id", right_on="bus_id", how="inner") lines = lines.merge(ac_reg_mult, on=["name_abbr", "kV", "MW"], how="left") lines.rename(columns={ "name_abbr": "reg_from", "mult": "mult_from" }, inplace=True) # take average between 2 buses' region multipliers lines.loc[:, "mult"] = (lines["mult_to"] + lines["mult_from"]) / 2.0 # calculate MWmi lines.loc[:, "lengthMi"] = lines.apply(lambda x: haversine( (x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1) lines.loc[:, "MWmi"] = lines["lengthMi"] * lines["rateA"] # calculate cost of each line lines.loc[:, "Cost"] = lines["MWmi"] * lines["costMWmi"] * lines["mult"] # calculate transformer costs transformers["per_MW_cost"] = transformers.apply( lambda x: xfmr_cost.iloc[ xfmr_cost.index.get_loc(bus.loc[x.from_bus_id, "baseKV"], method="nearest"), xfmr_cost.columns.get_loc(bus.loc[x.to_bus_id, "baseKV"], method="nearest"), ], axis=1, ) transformers["mult"] = transformers.apply( lambda x: get_transformer_mult(x, bus_reg, ac_reg_mult), axis=1) transformers["Cost"] = (transformers["rateA"] * transformers["per_MW_cost"] * transformers["mult"]) lines.Cost *= calculate_inflation(2010) transformers.Cost *= calculate_inflation(2020) if sum_results: return { "line_cost": lines.Cost.sum(), "transformer_cost": transformers.Cost.sum(), } else: return {"line_cost": lines, "transformer_cost": transformers}
def _calculate_ac_inv_costs(grid_new, sum_results=True): """Calculate cost of upgrading AC lines and/or transformers. NEEM regions are used to find regional multipliers. Note that a transformer winding is considered as a transformer. :param powersimdata.input.grid.Grid grid_new: grid instance. :param bool sum_results: whether to sum data frame for each branch type. Defaults to True. :return: (*dict*) -- keys are {'line_cost', 'transformer_cost'}, values are either float if ``sum_results``, or pandas Series indexed by branch ID. Whether summed or not, values are $USD, inflation-adjusted to today. """ def select_mw(x, cost_df): """Determine the closest kV/MW combination for a single branch and return the corresponding cost (in $/MW-mi). :param pandas.Series x: data for a single branch :param pandas.DataFrame cost_df: data frame with *'kV'*, *'MW'*, *'costMWmi'* as columns :return: (*pandas.Series*) -- series of [*'MW'*, *'costMWmi'*] to be assigned to branch. """ underground_regions = ("NEISO", "NYISO J-K") filtered_cost_df = cost_df.copy() # Unless we are entirely within an underground region, drop this cost class if not (x.from_region == x.to_region and x.from_region in underground_regions): filtered_cost_df = filtered_cost_df.query("kV != 345 or MW != 500") # select corresponding cost table of selected kV filtered_cost_df = filtered_cost_df[filtered_cost_df["kV"] == x.kV] # get rid of NaN values in this kV table filtered_cost_df = filtered_cost_df[~filtered_cost_df["MW"].isna()] # find closest MW & corresponding cost filtered_cost_df = filtered_cost_df.iloc[ np.argmin(np.abs(filtered_cost_df["MW"] - x.rateA)) ] return filtered_cost_df.loc[["MW", "costMWmi"]] def get_branch_mult(x, bus_reg, ac_reg_mult, branch_lookup_alerted=set()): """Determine the regional multiplier based on kV and power (closest). :param pandas.Series x: data for a single branch. :param pandas.DataFrame bus_reg: data frame with bus regions. :param pandas.DataFrame ac_reg_mult: data frame with regional multipliers. :param set branch_lookup_alerted: set of (voltage, region) tuples for which a message has already been printed that this lookup was not found. :return: (*float*) -- regional multiplier. """ # Select the highest voltage for transformers (branch end voltages should match) max_kV = bus.loc[[x.from_bus_id, x.to_bus_id], "baseKV"].max() # noqa: N806 # Average the multipliers for branches (transformer regions should match) regions = (x.from_region, x.to_region) region_mults = ac_reg_mult.loc[ac_reg_mult.name_abbr.isin(regions)] region_mults = region_mults.groupby(["kV", "MW"]).mean().reset_index() mult_lookup_kV = region_mults.loc[ # noqa: N806 (region_mults.kV - max_kV).abs().idxmin() ].kV region_kV_mults = region_mults[region_mults.kV == mult_lookup_kV] # noqa: N806 region_kV_mults = region_kV_mults.loc[ # noqa: N806 ~region_kV_mults.mult.isnull() ] if len(region_kV_mults) == 0: mult = 1 if (mult_lookup_kV, regions) not in branch_lookup_alerted: print(f"No multiplier for voltage {mult_lookup_kV} in {regions}") branch_lookup_alerted.add((mult_lookup_kV, regions)) else: mult_lookup_MW = region_kV_mults.loc[ # noqa: N806 (region_kV_mults.MW - x.rateA).abs().idxmin(), "MW" ] mult = ( region_kV_mults.loc[region_kV_mults.MW == mult_lookup_MW].squeeze().mult ) return mult # import data ac_cost = pd.DataFrame(const.ac_line_cost) ac_reg_mult = pd.read_csv(const.ac_reg_mult_path) ac_reg_mult = ac_reg_mult.melt( id_vars=["kV", "MW"], var_name="name_abbr", value_name="mult" ) try: bus_reg = pd.read_csv(const.bus_neem_regions_path, index_col="bus_id") except FileNotFoundError: bus_reg = bus_to_neem_reg(grid_new.bus) bus_reg.sort_index().to_csv(const.bus_neem_regions_path) xfmr_cost = pd.read_csv(const.transformer_cost_path, index_col=0).fillna(0) xfmr_cost.columns = [int(c) for c in xfmr_cost.columns] # Mirror across diagonal xfmr_cost += xfmr_cost.to_numpy().T - np.diag(np.diag(xfmr_cost.to_numpy())) # check that all buses included in this file and lat/long values match, # otherwise re-run mapping script on mis-matching buses. These buses are missing # in region file bus = grid_new.bus mapped_buses = bus.query("index in @bus_reg.index") missing_bus_indices = set(bus.index) - set(bus_reg.index) mapped_buses = merge_keep_index(mapped_buses, bus_reg, how="left", on="bus_id") # these buses have incorrect lat/lon values in the region mapping file. # re-running the region mapping script on those buses only. misaligned_bus_indices = mapped_buses[ ~np.isclose(mapped_buses.lat_x, mapped_buses.lat_y) | ~np.isclose(mapped_buses.lon_x, mapped_buses.lon_y) ].index all_buses_to_fix = set(missing_bus_indices) | set(misaligned_bus_indices) # fix the identified buses, if necessary if len(all_buses_to_fix) > 0: bus_fix = bus_to_neem_reg(bus.query("index in @all_buses_to_fix")) fix_cols = ["name_abbr", "lat", "lon"] corrected_bus_mappings = bus_fix.loc[misaligned_bus_indices, fix_cols] new_bus_mappings = bus_fix.loc[missing_bus_indices, fix_cols] bus_reg.loc[misaligned_bus_indices, fix_cols] = corrected_bus_mappings bus_reg = append_keep_index_name(bus_reg, new_bus_mappings) bus_reg.drop(["lat", "lon"], axis=1, inplace=True) # Add extra information to branch data frame branch = grid_new.branch branch.loc[:, "kV"] = bus.loc[branch.from_bus_id, "baseKV"].tolist() branch.loc[:, "from_region"] = bus_reg.loc[branch.from_bus_id, "name_abbr"].tolist() branch.loc[:, "to_region"] = bus_reg.loc[branch.to_bus_id, "name_abbr"].tolist() # separate transformers and lines t_mask = branch["branch_device_type"].isin(["Transformer", "TransformerWinding"]) transformers = branch[t_mask].copy() lines = branch[~t_mask].copy() if len(lines) > 0: # Find closest kV rating lines.loc[:, "kV"] = lines.apply( lambda x: ac_cost.loc[(ac_cost["kV"] - x.kV).abs().idxmin(), "kV"], axis=1, ) lines[["MW", "costMWmi"]] = lines.apply(lambda x: select_mw(x, ac_cost), axis=1) lines["mult"] = lines.apply( lambda x: get_branch_mult(x, bus_reg, ac_reg_mult), axis=1 ) # calculate MWmi lines.loc[:, "lengthMi"] = lines.apply( lambda x: haversine((x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1 ) else: new_columns = ["kV", "MW", "costMWmi", "mult", "lengthMi"] lines = lines.reindex(columns=[*lines.columns.tolist(), *new_columns]) lines.loc[:, "MWmi"] = lines["lengthMi"] * lines["rateA"] # calculate cost of each line lines.loc[:, "cost"] = lines["MWmi"] * lines["costMWmi"] * lines["mult"] # calculate transformer costs if len(transformers) > 0: from_to_kv = [ xfmr_cost.index.get_indexer( bus.loc[transformers[e], "baseKV"], method="nearest" ) for e in ["from_bus_id", "to_bus_id"] ] transformers["per_MW_cost"] = [ xfmr_cost.iloc[f, t] for f, t in zip(from_to_kv[0], from_to_kv[1]) ] transformers["mult"] = transformers.apply( lambda x: get_branch_mult(x, bus_reg, ac_reg_mult), axis=1 ) else: # Properly handle case with no transformers, where apply returns wrong dims transformers["per_MW_cost"] = [] transformers["mult"] = [] transformers["cost"] = ( transformers["rateA"] * transformers["per_MW_cost"] * transformers["mult"] ) lines.cost *= calculate_inflation(2010) transformers.cost *= calculate_inflation(2020) if sum_results: return { "line_cost": lines.cost.sum(), "transformer_cost": transformers.cost.sum(), } else: return {"line_cost": lines.cost, "transformer_cost": transformers.cost}
def _identify_mesh_branch_upgrades( ref_scenario, upgrade_n=100, allow_list=None, deny_list=None, congestion_metric="quantile", cost_metric="branches", quantile=None, ): """Identify the N most congested branches in a previous scenario, based on the quantile value of congestion duals, where N is specified by ``upgrade_n``. A quantile value of 0.95 obtains the branches with highest dual in top 5% of hours. :param powersimdata.scenario.scenario.Scenario ref_scenario: the reference scenario to be used to determine the most congested branches. :param int upgrade_n: the number of branches to upgrade. :param list/set/tuple/None allow_list: only select from these branch IDs. :param list/set/tuple/None deny_list: never select any of these branch IDs. :param str congestion_metric: numerator method: 'quantile' or 'mean'. :param str cost_metric: denominator method: 'branches', 'cost', 'MW', or 'MWmiles'. :param float quantile: if ``congestion_metric`` == 'quantile', this is the quantile to use to judge branch congestion (otherwise it is unused). If None, a default value of 0.95 is used, i.e. we evaluate the shadow price for the worst 5% of hours. :raises ValueError: if ``congestion_metric`` or ``cost_metric`` is not recognized, ``congestion_metric`` == 'mean' but a ``quantile`` is specified, or ``congestion_metric`` == 'quantile' but there are not enough branches which are congested at the desired frequency based on the ``quantile`` specified. :return: (*set*) -- A set of ints representing branch indices. """ # How big does a dual value have to be to be 'real' and not barrier cruft? cong_significance_cutoff = 1e-6 # $/MWh # If we rank by MW-miles, what 'length' do we give to zero-length branches? zero_length_value = 1 # miles # If the quantile is not provided, what should we default to? default_quantile = 0.95 # Validate congestion_metric input allowed_congestion_metrics = ("mean", "quantile") if congestion_metric not in allowed_congestion_metrics: allowed_list = ", ".join(allowed_congestion_metrics) raise ValueError(f"congestion_metric must be one of: {allowed_list}") if congestion_metric == "mean" and quantile is not None: raise ValueError("quantile cannot be specified if congestion_metric is 'mean'") if congestion_metric == "quantile" and quantile is None: quantile = default_quantile # Validate cost_metric input allowed_cost_metrics = ("branches", "MW", "MWmiles", "cost") if cost_metric not in allowed_cost_metrics: allowed_list = ", ".join(allowed_cost_metrics) raise ValueError(f"cost_metric must be one of: {allowed_list}") # Get raw congestion dual values, add them ref_cong_abs = ref_scenario.state.get_congu() + ref_scenario.state.get_congl() all_branches = set(ref_cong_abs.columns.tolist()) # Create validated composite allow list, and filter shadow price data frame composite_allow_list = _construct_composite_allow_list( all_branches, allow_list, deny_list ) ref_cong_abs = ref_cong_abs.filter(items=composite_allow_list) if congestion_metric == "mean": congestion_metric_values = ref_cong_abs.mean() if congestion_metric == "quantile": congestion_metric_values = ref_cong_abs.quantile(quantile) # Filter out 'insignificant' values congestion_metric_values = congestion_metric_values.where( congestion_metric_values > cong_significance_cutoff ).dropna() # Filter based on composite allow list congested_indices = list(congestion_metric_values.index) # Ensure that we have enough congested branches to upgrade num_congested = len(congested_indices) if num_congested < upgrade_n: err_msg = "not enough congested branches: " err_msg += f"{upgrade_n} desired, but only {num_congested} congested." if congestion_metric == "quantile": err_msg += ( f" The quantile used is {quantile}; increasing this value will increase" " the number of branches which qualify as having 'frequent-enough'" " congestion and can be selected for upgrades." ) raise ValueError(err_msg) # Calculate selected cost metric for congested branches if cost_metric == "cost": # Calculate costs for an upgrade dataframe containing only composite_allow_list base_grid = ref_scenario.get_base_grid() base_grid.branch = base_grid.branch.filter(items=congested_indices, axis=0) upgrade_costs = _calculate_ac_inv_costs(base_grid, sum_results=False) # Merge the individual line/transformer data into a single Series merged_upgrade_costs = pd.concat([v for v in upgrade_costs.values()]) if cost_metric in ("MW", "MWmiles"): ref_grid = ref_scenario.state.get_grid() branch_ratings = ref_grid.branch.loc[congested_indices, "rateA"] # Calculate 'original' branch capacities, since that's our increment ref_ct = ref_scenario.state.get_ct() try: branch_ct = ref_ct["branch"]["branch_id"] except KeyError: branch_ct = {} branch_prev_scaling = pd.Series( {i: (branch_ct[i] if i in branch_ct else 1) for i in congested_indices} ) branch_ratings = branch_ratings / branch_prev_scaling # Then, apply this metric if cost_metric == "MW": branch_metric = congestion_metric_values / branch_ratings elif cost_metric == "MWmiles": branch_lengths = ref_grid.branch.loc[congested_indices].apply( lambda x: haversine((x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1 ) # Replace zero-length branches by designated default, don't divide by 0 branch_lengths = branch_lengths.replace(0, value=zero_length_value) branch_metric = congestion_metric_values / (branch_ratings * branch_lengths) elif cost_metric == "cost": branch_metric = congestion_metric_values / merged_upgrade_costs else: # By process of elimination, all that's left is method 'branches' branch_metric = congestion_metric_values # Sort by our metric, grab indexes for N largest values (tail), return ranked_branches = set(branch_metric.sort_values().tail(upgrade_n).index) return ranked_branches
def _identify_mesh_branch_upgrades( ref_scenario, upgrade_n=100, quantile=0.95, allow_list=None, deny_list=None, method="branches", ): """Identify the N most congested branches in a previous scenario, based on the quantile value of congestion duals. A quantile value of 0.95 obtains the branches with highest dual in top 5% of hours. :param powersimdata.scenario.scenario.Scenario ref_scenario: the reference scenario to be used to determine the most congested branches. :param int upgrade_n: the number of branches to upgrade. :param float quantile: the quantile to use to judge branch congestion. :param list/set/tuple/None allow_list: only select from these branch IDs. :param list/set/tuple/None deny_list: never select any of these branch IDs. :param str method: prioritization method: 'branches', 'MW', or 'MWmiles'. :raises ValueError: if 'method' not recognized, or not enough branches to upgrade. :return: (*set*) -- A set of ints representing branch indices. """ # How big does a dual value have to be to be 'real' and not barrier cruft? cong_significance_cutoff = 1e-6 # $/MWh # If we rank by MW-miles, what 'length' do we give to zero-length branches? zero_length_value = 1 # miles # Validate method input allowed_methods = ("branches", "MW", "MWmiles", "cost") if method not in allowed_methods: allowed_list = ", ".join(allowed_methods) raise ValueError(f"method must be one of: {allowed_list}") # Get raw congestion dual values, add them ref_cong_abs = ref_scenario.state.get_congu( ) + ref_scenario.state.get_congl() all_branches = set(ref_cong_abs.columns.tolist()) # Create validated composite allow list composite_allow_list = _construct_composite_allow_list( all_branches, allow_list, deny_list) # Parse 2-D array to vector of quantile values ref_cong_abs = ref_cong_abs.filter(items=composite_allow_list) quantile_cong_abs = ref_cong_abs.quantile(quantile) # Filter out insignificant values significance_bitmask = quantile_cong_abs > cong_significance_cutoff quantile_cong_abs = quantile_cong_abs.where(significance_bitmask).dropna() # Filter based on composite allow list congested_indices = list(quantile_cong_abs.index) # Ensure that we have enough congested branches to upgrade num_congested = len(quantile_cong_abs) if num_congested < upgrade_n: err_msg = "not enough congested branches: " err_msg += f"{upgrade_n} desired, but only {num_congested} congested." raise ValueError(err_msg) # Calculate selected metric for congested branches if method == "cost": # Calculate costs for an upgrade dataframe containing only composite_allow_list base_grid = Grid(ref_scenario.info["interconnect"], ref_scenario.info["grid_model"]) base_grid.branch = base_grid.branch.filter(items=congested_indices, axis=0) upgrade_costs = _calculate_ac_inv_costs(base_grid, sum_results=False) # Merge the individual line/transformer data into a single Series merged_upgrade_costs = pd.concat([v for v in upgrade_costs.values()]) if method in ("MW", "MWmiles"): ref_grid = ref_scenario.state.get_grid() branch_ratings = ref_grid.branch.loc[congested_indices, "rateA"] # Calculate 'original' branch capacities, since that's our increment ref_ct = ref_scenario.state.get_ct() try: branch_ct = ref_ct["branch"]["branch_id"] except KeyError: branch_ct = {} branch_prev_scaling = pd.Series({ i: (branch_ct[i] if i in branch_ct else 1) for i in congested_indices }) branch_ratings = branch_ratings / branch_prev_scaling if method == "MW": branch_metric = quantile_cong_abs / branch_ratings elif method == "MWmiles": branch_lengths = ref_grid.branch.loc[congested_indices].apply( lambda x: haversine((x.from_lat, x.from_lon), (x.to_lat, x.to_lon)), axis=1) # Replace zero-length branches by designated default, don't divide by 0 branch_lengths = branch_lengths.replace(0, value=zero_length_value) branch_metric = quantile_cong_abs / (branch_ratings * branch_lengths) elif method == "cost": branch_metric = quantile_cong_abs / merged_upgrade_costs else: # By process of elimination, all that's left is method 'branches' branch_metric = quantile_cong_abs # Sort by our metric, grab indexes for N largest values (tail), return ranked_branches = set(branch_metric.sort_values().tail(upgrade_n).index) return ranked_branches
def map_interconnections( grid, state_counts, hover_choice, hvdc_width=1, us_states_dat=None ): """Maps transmission lines color coded by interconnection. :param powersimdata.input.grid.Grid grid: grid object. :param pandas.DataFrame state_counts: state names and node counts, created by :func:`count_nodes_per_state`. :param str hover_choice: "nodes" for state_counts nodes per state, otherwise HVDC capacity in hover over tool tips for hvdc lines only. :param float hvdc_width: adjust width of HVDC lines on map. :param dict us_states_dat: dictionary of state border lats/lons. If None, get from :func:`postreise.plot.plot_states.get_state_borders`. :return: (*bokeh.plotting.figure*) -- map of transmission lines. """ if us_states_dat is None: us_states_dat = get_state_borders() # projection steps for mapping branch = grid.branch branch_bus = grid.bus branch_map = project_branch(branch) branch_map["point1"] = list(zip(branch_map.to_lat, branch_map.to_lon)) branch_map["point2"] = list(zip(branch_map.from_lat, branch_map.from_lon)) branch_map["dist"] = branch_map.apply( lambda row: distance.haversine(row["point1"], row["point2"]), axis=1 ) # speed rendering on website by removing very short branches branch_map = branch_map.loc[branch_map.dist > 5] branch_west = branch_map.loc[branch_map.interconnect == "Western"] branch_east = branch_map.loc[branch_map.interconnect == "Eastern"] branch_tx = branch_map.loc[branch_map.interconnect == "Texas"] branch_mdc = grid.dcline branch_mdc["from_lon"] = branch_bus.loc[branch_mdc.from_bus_id, "lon"].values branch_mdc["from_lat"] = branch_bus.loc[branch_mdc.from_bus_id, "lat"].values branch_mdc["to_lon"] = branch_bus.loc[branch_mdc.to_bus_id, "lon"].values branch_mdc["to_lat"] = branch_bus.loc[branch_mdc.to_bus_id, "lat"].values branch_mdc = project_branch(branch_mdc) # back to backs are index 0-8, treat separately branch_mdc1 = branch_mdc.iloc[ 9:, ] b2b = branch_mdc.iloc[ 0:9, ] branch_mdc_leg = branch_mdc branch_mdc_leg.loc[0:8, ["to_x"]] = np.nan branch_mdc_leg["to_x"] = branch_mdc_leg["to_x"].fillna(branch_mdc_leg["from_x"]) branch_mdc_leg.loc[0:8, ["to_y"]] = np.nan branch_mdc_leg["to_y"] = branch_mdc_leg["to_y"].fillna(branch_mdc_leg["from_y"]) # pseudolines for legend to show hvdc and back to back, plot UNDER map multi_line_source6 = ColumnDataSource( { "xs": branch_mdc_leg[["from_x", "to_x"]].values.tolist(), "ys": branch_mdc_leg[["from_y", "to_y"]].values.tolist(), "capacity": branch_mdc_leg.Pmax.astype(float) * 0.00023 + 0.2, "cap": branch_mdc_leg.Pmax.astype(float), } ) # state borders a, b = project_borders(us_states_dat, state_list=list(state_counts["state"])) # transmission data sources line_width_const = 0.000225 multi_line_source = ColumnDataSource( { "xs": branch_west[["from_x", "to_x"]].values.tolist(), "ys": branch_west[["from_y", "to_y"]].values.tolist(), "capacity": branch_west.rateA * line_width_const + 0.1, } ) multi_line_source2 = ColumnDataSource( { "xs": branch_east[["from_x", "to_x"]].values.tolist(), "ys": branch_east[["from_y", "to_y"]].values.tolist(), "capacity": branch_east.rateA * line_width_const + 0.1, } ) multi_line_source3 = ColumnDataSource( { "xs": branch_tx[["from_x", "to_x"]].values.tolist(), "ys": branch_tx[["from_y", "to_y"]].values.tolist(), "capacity": branch_tx.rateA * line_width_const + 0.1, } ) # hvdc multi_line_source4 = ColumnDataSource( { "xs": branch_mdc1[["from_x", "to_x"]].values.tolist(), "ys": branch_mdc1[["from_y", "to_y"]].values.tolist(), "capacity": branch_mdc1.Pmax.astype(float) * line_width_const * hvdc_width + 0.1, "cap": branch_mdc1.Pmax.astype(float), } ) # pseudolines for ac multi_line_source5 = ColumnDataSource( { "xs": b2b[["from_x", "to_x"]].values.tolist(), "ys": b2b[["from_y", "to_y"]].values.tolist(), "capacity": b2b.Pmax.astype(float) * 0.00023 + 0.2, "cap": b2b.Pmax.astype(float), "col": ( "#006ff9", "#006ff9", "#006ff9", "#006ff9", "#006ff9", "#006ff9", "#006ff9", "#8B36FF", "#8B36FF", ), } ) # lower 48 states, patches source = ColumnDataSource( dict( xs=a, ys=b, col=["gray" for i in range(48)], col2=["gray" for i in range(48)], label=list(state_counts["count"]), state_name=list(state_counts["state"]), ) ) # Set up figure tools: str = "pan, wheel_zoom, reset, save" p = figure( tools=tools, x_axis_location=None, y_axis_location=None, plot_width=800, plot_height=800, output_backend="webgl", sizing_mode="stretch_both", match_aspect=True, ) # for legend, hidden lines leg_clr = ["#006ff9", "#8B36FF", "#01D4ED", "#FF2370"] leg_lab = ["Western", "Eastern", "Texas", "HVDC"] leg_xs = [-1.084288e07] * 4 leg_ys = [4.639031e06] * 4 for (colr, leg, x, y) in zip(leg_clr, leg_lab, leg_xs, leg_ys): p.line(x, y, color=colr, width=5, legend=leg) # pseudo lines for hover tips lines = p.multi_line( "xs", "ys", color="black", line_width="capacity", source=multi_line_source6 ) # background tiles p.add_tile(get_provider(Vendors.CARTODBPOSITRON)) # state borders patch = p.patches("xs", "ys", fill_alpha=0.0, line_color="col", source=source) # branches source_list = [multi_line_source, multi_line_source2, multi_line_source3] for (colr, source) in zip(leg_clr[0:3], source_list): p.multi_line("xs", "ys", color=colr, line_width="capacity", source=source) p.multi_line( "xs", "ys", color="#FF2370", line_width="capacity", source=multi_line_source4 ) # pseudo ac p.multi_line( "xs", "ys", color="col", line_width="capacity", source=multi_line_source5 ) # triangles for b2b p.scatter( x=b2b.from_x, y=b2b.from_y, color="#FF2370", marker="triangle", size=b2b.Pmax / 50 + 5, legend="Back-to-Back", ) # legend formatting p.legend.location = "bottom_right" p.legend.label_text_font_size = "12pt" if hover_choice == "nodes": hover = HoverTool( tooltips=[ ("State", "@state_name"), ("Nodes", "@label"), ], renderers=[patch], ) else: hover = HoverTool( tooltips=[ ("HVDC capacity MW", "@cap"), ], renderers=[lines], ) p.add_tools(hover) return p