def test_calculate_ac_inv_costs_not_summed(mock_grid): inflation_2010 = calculate_inflation(2010) inflation_2020 = calculate_inflation(2020) expected_ac_cost = { # ((reg_mult1 + reg_mult2) / 2) * sum(basecost * rateA * miles) "line_cost": { 10: 0, # This branch would normally be dropped by calculate_ac_inv_costs 11: ((1 + 2.25) / 2) * 3666.67 * 10 * 679.179925842 * inflation_2010, 12: ((1 + 2.25) / 2) * 1500 * 1100 * 680.986501516 * inflation_2010, 15: ((1 + 1) / 2) * 2333.33 * 50 * 20.003889808 * inflation_2010, }, # for each: rateA * basecost * regional multiplier "transformer_cost": { 13: (30 * 7670 * 1) * inflation_2020, 14: (40 * 8880 * 2.25) * inflation_2020, }, } ac_cost = _calculate_ac_inv_costs(mock_grid, sum_results=False) for branch_type, upgrade_costs in expected_ac_cost.items(): assert set(upgrade_costs.keys()) == set(ac_cost[branch_type].index) for branch, cost in upgrade_costs.items(): assert cost == pytest.approx(ac_cost[branch_type].loc[branch])
def test_calculate_dc_inv_costs(mock_grid): expected_dc_cost = ( # lines 10 * 679.1799258421203 * 457.1428571 * calculate_inflation(2015) # terminals + 135e3 * 10 * 2 * calculate_inflation(2020)) dc_cost = _calculate_dc_inv_costs(mock_grid) assert dc_cost == pytest.approx(expected_dc_cost)
def test_calculate_dc_inv_costs(mock_grid): expected_dc_cost = ( # lines calculate_inflation(2015) * 457.1428571 * (10 * 679.1799258421203 + 200 * 20.003889808) # terminals + 135e3 * (10 + 200) * 2 * calculate_inflation(2020)) dc_cost = _calculate_dc_inv_costs(mock_grid) assert dc_cost == pytest.approx(expected_dc_cost)
def test_calculate_dc_inv_costs_not_summed(mock_grid): expected_dc_cost = { 5: (457.1428571 * 10 * 679.1799258421203 * calculate_inflation(2015) + 135e3 * 10 * 2 * calculate_inflation(2020)), 14: (457.1428571 * 200 * 20.003889808 * calculate_inflation(2015) + 135e3 * 200 * 2 * calculate_inflation(2020)), } dc_cost = _calculate_dc_inv_costs(mock_grid, sum_results=False) for dcline_id, expected_cost in expected_dc_cost.items(): assert expected_cost == pytest.approx(dc_cost.loc[dcline_id])
def test_calculate_ac_inv_costs(mock_grid): expected_ac_cost = { # ((reg_mult1 + reg_mult2) / 2) * sum(basecost * rateA * miles) "line_cost": (((1 + 2.25) / 2) * (3666.67 * 10 * 679.179925842 + 1500 * 1100 * 680.986501516) * calculate_inflation(2010)), # for each: rateA * basecost * regional multiplier "transformer_cost": ((30 * 7670 * 1) + (40 * 8880 * 2.25)) * calculate_inflation(2020), } ac_cost = _calculate_ac_inv_costs(mock_grid) assert ac_cost.keys() == expected_ac_cost.keys() for k in ac_cost.keys(): assert ac_cost[k] == pytest.approx(expected_ac_cost[k])
def test_calculate_gen_inv_costs_2030(mock_grid): gen_inv_cost = _calculate_gen_inv_costs(mock_grid, 2030, "Moderate").to_dict() expected_gen_inv_cost = { # for each: capacity (kW) * regional multiplier * base technology cost "solar": sum([ 15e3 * 1.01701 * 836.3842785, 12e3 * 1.01701 * 836.3842785, 8e3 * 1.01701 * 836.3842785, ]), "coal": 30e3 * 1.05221 * 4049.047403, "wind": 10e3 * 1.16979 * 1297.964758 + 15e3 * 1.04348 * 1297.964758, "ng": 20e3 * 1.050755 * 983.2351768, "storage": 100e3 * 1.012360 * 817 + 200e3 * 1.043730 * 817, "nuclear": 1000e3 * 1.07252 * 6727.799801, } inflation = calculate_inflation(2018) expected_gen_inv_cost = { k: v * inflation for k, v in expected_gen_inv_cost.items() } assert gen_inv_cost.keys() == expected_gen_inv_cost.keys() for k in gen_inv_cost.keys(): assert gen_inv_cost[k] == pytest.approx(expected_gen_inv_cost[k])
def test_calculate_gen_inv_costs_not_summed(mock_grid): gen_inv_cost = _calculate_gen_inv_costs(mock_grid, 2025, "Advanced", sum_results=False) expected_gen_inv_cost = { # for each: capacity (kW) * regional multiplier * base technology cost 3: 15e3 * 1.01701 * 1013.912846, 5: 30e3 * 1.05221 * 4099.115851, 6: 10e3 * 1.16979 * 1301.120135, 7: 12e3 * 1.01701 * 1013.912846, 8: 8e3 * 1.01701 * 1013.912846, 9: 20e3 * 1.050755 * 1008.001936, 10: 15e3 * 1.04348 * 1301.120135, 11: 1000e3 * 1.07252 * 6928.866991, 12: 100e3 * 1.012360 * 779, 13: 200e3 * 1.043730 * 779, } inflation = calculate_inflation(2018) expected_gen_inv_cost = { k: v * inflation for k, v in expected_gen_inv_cost.items() } assert set(gen_inv_cost.index) == set(expected_gen_inv_cost.keys()) for k in gen_inv_cost.index: assert gen_inv_cost.loc[k] == pytest.approx(expected_gen_inv_cost[k])
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 _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 test_calculate_ac_inv_costs_transformers_only(mock_grid): expected_ac_cost = { # ((reg_mult1 + reg_mult2) / 2) * sum(basecost * rateA * miles) "line_cost": 0, # for each: rateA * basecost * regional multiplier "transformer_cost": ((30 * 7670 * 1) + (40 * 8880 * 2.25)) * calculate_inflation(2020), } this_grid = copy.deepcopy(mock_grid) this_grid.branch = this_grid.branch.query( "branch_device_type == 'Transformer'") ac_cost = _calculate_ac_inv_costs(this_grid) assert ac_cost.keys() == expected_ac_cost.keys() for k in ac_cost.keys(): assert ac_cost[k] == pytest.approx(expected_ac_cost[k])
def test_calculate_ac_inv_costs_lines_only(mock_grid): expected_ac_cost = { # ((reg_mult1 + reg_mult2) / 2) * sum(basecost * rateA * miles) "line_cost": (calculate_inflation(2010) * ((((1 + 2.25) / 2) * (3666.67 * 10 * 679.179925842 + 1500 * 1100 * 680.986501516)) + ((1 + 1) / 2) * 2333.33 * 50 * 20.003889808)), # for each: rateA * basecost * regional multiplier "transformer_cost": 0, } this_grid = copy.deepcopy(mock_grid) this_grid.branch = this_grid.branch.query("branch_device_type == 'Line'") ac_cost = _calculate_ac_inv_costs(this_grid) assert ac_cost.keys() == expected_ac_cost.keys() for k in ac_cost.keys(): assert ac_cost[k] == pytest.approx(expected_ac_cost[k])
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_gen_inv_costs(grid_new, year, cost_case, sum_results=True): """Given a grid, calculate the total cost of building that generation investment. Computes total capital cost as CAPEX_total = CAPEX ($/MW) * Pmax (MW) * reg_cap_cost_mult (regional cost multiplier) This function is separate from calculate_gen_inv_costs() for testing purposes. Currently only uses one (arbutrary) sub-technology. Drops the rest of the costs. Will want to fix for wind/solar (based on resource supply curves). Currently uses ReEDS regions to find regional multipliers. :param powersimdata.input.grid.Grid grid_new: grid instance. :param int/str year: year of builds (used in financials). :param str cost_case: the ATB cost case of data: 'Moderate': mid cost case 'Conservative': generally higher costs 'Advanced': generally lower costs :raises ValueError: if year not 2020 - 2050, or cost case not an allowed option. :raises TypeError: if year gets the wrong type, or if cost_case is not str. :return: (*pandas.Series*) -- Total generation investment cost, summed by technology. """ def load_cost(year, cost_case): """ Load in base costs from NREL's 2020 ATB for generation technologies (CAPEX). Can be adapted in the future for FOM, VOM, & CAPEX. This data is pulled from the ATB xlsx file Summary pages (saved as csv's). Therefore, currently uses default financials, but will want to create custom financial functions in the future. :param int/str year: year of cost projections. :param str cost_case: the ATB cost case of data (see :py:func:`write_poly_shapefile` for details). :return: (*pandas.DataFrame*) -- Cost by technology/subtype (in $2018). """ cost = pd.read_csv(const.gen_inv_cost_path) cost = cost.dropna(axis=0, how="all") # drop non-useful columns cols_drop = cost.columns[~cost.columns. isin([str(x) for x in cost.columns[0:6]] + ["Metric", str(year)])] cost.drop(cols_drop, axis=1, inplace=True) # rename year of interest column cost.rename(columns={str(year): "value"}, inplace=True) # get rid of #refs cost.drop(cost[cost["value"] == "#REF!"].index, inplace=True) # get rid of $s, commas cost["value"] = cost["value"].str.replace("$", "", regex=True) cost["value"] = cost["value"].str.replace(",", "", regex=True).astype("float64") # scale from $/kW to $/MW cost["value"] *= 1000 cost.rename(columns={"value": "CAPEX"}, inplace=True) # select scenario of interest cost = cost[cost["CostCase"] == cost_case] cost.drop(["CostCase"], axis=1, inplace=True) return cost if isinstance(year, (int, str)): year = int(year) if year not in range(2020, 2051): raise ValueError("year not in range.") else: raise TypeError("year must be int or str.") if isinstance(cost_case, str): if cost_case not in ["Moderate", "Conservative", "Advanced"]: raise ValueError( "cost_case not Moderate, Conservative, or Advanced") else: raise TypeError("cost_case must be str.") plants = grid_new.plant.append(grid_new.storage["gen"]) plants = plants[~plants.type.isin( ["dfo", "other"])] # drop these technologies, no cost data # BASE TECHNOLOGY COST # load in investment costs $/MW gen_costs = load_cost(year, cost_case) # keep only certain (arbitrary) subclasses for now gen_costs = gen_costs[gen_costs["TechDetail"].isin( const.gen_inv_cost_techdetails_to_keep)] # rename techs to match grid object gen_costs.replace(const.gen_inv_cost_translation, inplace=True) gen_costs.drop(["Key", "FinancialCase", "CRPYears"], axis=1, inplace=True) # ATB technology costs merge plants = plants.merge(gen_costs, right_on="Technology", left_on="type", how="left") # REGIONAL COST MULTIPLIER # Find ReEDS regions of plants (for regional cost multipliers) plant_buses = plants.bus_id.unique() try: bus_reg = pd.read_csv(const.bus_reeds_regions_path, index_col="bus_id") if not set(plant_buses) <= set(bus_reg.index): missing_buses = set(plant_buses) - set(bus_reg.index) bus_reg = bus_reg.append( bus_to_reeds_reg(grid_new.bus.loc[missing_buses])) bus_reg.sort_index().to_csv(const.bus_reeds_regions_path) except FileNotFoundError: bus_reg = bus_to_reeds_reg(grid_new.bus.loc[plant_buses]) bus_reg.sort_index().to_csv(const.bus_reeds_regions_path) plants = plants.merge(bus_reg, left_on="bus_id", right_index=True, how="left") # Determine one region 'r' for each plant, based on one of two mappings plants.loc[:, "r"] = "" # Some types get regional multipliers via 'wind regions' ('rs') wind_region_mask = plants["type"].isin( const.regional_multiplier_wind_region_types) plants.loc[wind_region_mask, "r"] = plants.loc[wind_region_mask, "rs"] # Other types get regional multipliers via 'BA regions' ('rb') ba_region_mask = plants["type"].isin( const.regional_multiplier_ba_region_types) plants.loc[ba_region_mask, "r"] = plants.loc[ba_region_mask, "rb"] plants.drop(["rs", "rb"], axis=1, inplace=True) # merge regional multipliers with plants region_multiplier = pd.read_csv(const.regional_multiplier_path) region_multiplier.replace(const.regional_multiplier_gen_translation, inplace=True) plants = plants.merge(region_multiplier, left_on=["r", "Technology"], right_on=["r", "i"], how="left") # multiply all together to get summed CAPEX ($) plants.loc[:, "CAPEX_total"] = (plants["CAPEX"] * plants["Pmax"] * plants["reg_cap_cost_mult"]) # sum cost by technology plants.loc[:, "CAPEX_total"] *= calculate_inflation(2018) if sum_results: return plants.groupby(["Technology"])["CAPEX_total"].sum() else: return plants
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 _calculate_gen_inv_costs(grid_new, year, cost_case, sum_results=True): """Calculate cost of upgrading generators. ReEDS regions are used to find regional multipliers. :param powersimdata.input.grid.Grid grid_new: grid instance. :param int/str year: year of builds. :param str cost_case: ATB cost case of data. *'Moderate'*: mid cost case *'Conservative'*: generally higher costs, *'Advanced'*: generally lower costs. :raises ValueError: if year not 2020 - 2050, or cost case not an allowed option. :raises TypeError: if year not int/str or cost_case not str. :param bool sum_results: whether to sum data frame for plant costs. Defaults to True. :return: (*pandas.Series*) -- Overnight generation investment cost. If ``sum_results``, indices are technologies and values are total cost. Otherwise, indices are IDs of plants (including storage, which is given pseudo-plant-IDs), and values are individual generator costs. Whether summed or not, values are $USD, inflation-adjusted to today. .. note:: the function computes the total capital cost as: CAPEX_total = overnight CAPEX ($/MW) * Power capacity (MW) * regional multiplier """ def load_cost(year, cost_case): """Load in base costs from NREL's 2020 ATB for generation technologies (CAPEX). :param int/str year: year of cost projections. :param str cost_case: ATB cost case of data (see :return: (*pandas.DataFrame*) -- cost by technology/subtype in $2018. .. todo:: it can be adapted in the future for FOM, VOM, & CAPEX. This data is pulled from the ATB xlsx file summary pages. Therefore, it currently uses default financials, but will want to create custom financial functions in the future. """ cost = pd.read_csv(const.gen_inv_cost_path) cost = cost.dropna(axis=0, how="all") # drop non-useful columns cols_drop = cost.columns[ ~cost.columns.isin( [str(x) for x in cost.columns[0:6]] + ["Metric", str(year)] ) ] cost.drop(cols_drop, axis=1, inplace=True) # rename year of interest column cost.rename(columns={str(year): "value"}, inplace=True) # get rid of #refs cost.drop(cost[cost["value"] == "#REF!"].index, inplace=True) # get rid of $s, commas cost["value"] = cost["value"].str.replace("$", "", regex=True) cost["value"] = cost["value"].str.replace(",", "", regex=True).astype("float64") # scale from $/kW to $/MW cost["value"] *= 1000 cost.rename(columns={"value": "CAPEX"}, inplace=True) # select scenario of interest if cost_case != "Moderate": # The 2020 ATB only has "Moderate" for nuclear, so we need to make due. warnings.warn( f"No cost data available for Nuclear for {cost_case} cost case, " "using Moderate cost case data instead" ) new_nuclear = cost.query( "Technology == 'Nuclear' and CostCase == 'Moderate'" ).copy() new_nuclear.CostCase = cost_case cost = pd.concat([cost, new_nuclear], ignore_index=True) cost = cost[cost["CostCase"] == cost_case] cost.drop(["CostCase"], axis=1, inplace=True) return cost if isinstance(year, (int, str)): year = int(year) if year not in range(2020, 2051): raise ValueError("year not in range.") else: raise TypeError("year must be int or str.") if isinstance(cost_case, str): if cost_case not in ["Moderate", "Conservative", "Advanced"]: raise ValueError("cost_case not Moderate, Conservative, or Advanced") else: raise TypeError("cost_case must be str.") storage_plants = grid_new.storage["gen"].set_index( grid_new.storage["StorageData"].UnitIdx.astype(int) ) plants = append_keep_index_name(grid_new.plant, storage_plants) plants = plants[ ~plants.type.isin(["dfo", "other"]) ] # drop these technologies, no cost data # BASE TECHNOLOGY COST # load in investment costs $/MW gen_costs = load_cost(year, cost_case) # keep only certain (arbitrary) subclasses for now gen_costs = gen_costs[ gen_costs["TechDetail"].isin(const.gen_inv_cost_techdetails_to_keep) ] # rename techs to match grid object gen_costs.replace(const.gen_inv_cost_translation, inplace=True) gen_costs.drop(["Key", "FinancialCase", "CRPYears"], axis=1, inplace=True) # ATB technology costs merge plants = merge_keep_index( plants, gen_costs, right_on="Technology", left_on="type", how="left" ) # REGIONAL COST MULTIPLIER # Find ReEDS regions of plants (for regional cost multipliers) plant_buses = plants.bus_id.unique() try: bus_reg = pd.read_csv(const.bus_reeds_regions_path, index_col="bus_id") if not set(plant_buses) <= set(bus_reg.index): missing_buses = set(plant_buses) - set(bus_reg.index) bus_reg = bus_reg.append(bus_to_reeds_reg(grid_new.bus.loc[missing_buses])) bus_reg.sort_index().to_csv(const.bus_reeds_regions_path) except FileNotFoundError: bus_reg = bus_to_reeds_reg(grid_new.bus.loc[plant_buses]) bus_reg.sort_index().to_csv(const.bus_reeds_regions_path) plants = merge_keep_index( plants, bus_reg, left_on="bus_id", right_index=True, how="left" ) # Determine one region 'r' for each plant, based on one of two mappings plants.loc[:, "r"] = "" # Some types get regional multipliers via 'wind regions' ('rs') wind_region_mask = plants["type"].isin(const.regional_multiplier_wind_region_types) plants.loc[wind_region_mask, "r"] = plants.loc[wind_region_mask, "rs"] # Other types get regional multipliers via 'BA regions' ('rb') ba_region_mask = plants["type"].isin(const.regional_multiplier_ba_region_types) plants.loc[ba_region_mask, "r"] = plants.loc[ba_region_mask, "rb"] plants.drop(["rs", "rb"], axis=1, inplace=True) # merge regional multipliers with plants region_multiplier = pd.read_csv(const.regional_multiplier_path) region_multiplier.replace(const.regional_multiplier_gen_translation, inplace=True) plants = merge_keep_index( plants, region_multiplier, left_on=["r", "Technology"], right_on=["r", "i"], how="left", ) # multiply all together to get summed CAPEX ($) plants.loc[:, "cost"] = ( plants["CAPEX"] * plants["Pmax"] * plants["reg_cap_cost_mult"] ) # sum cost by technology plants.loc[:, "cost"] *= calculate_inflation(2018) if sum_results: return plants.groupby(["Technology"])["cost"].sum() else: return plants["cost"]