def fetch_atb_offshore_spur_costs(pudl_engine: sqlalchemy.engine.base.Engine, settings: dict) -> pd.DataFrame: """Load offshore spur-line costs and convert to desired dollar-year. Parameters ---------- pudl_engine : sqlalchemy.Engine A sqlalchemy connection for use by pandas settings : dict User-defined parameters from a settings file. Needs to have keys `atb_data_year` and `target_usd_year`. Returns ------- pd.DataFrame Total offshore spur line capex from ATB for each technology/tech_detail/ basis_year/cost_case combination. """ spur_costs = pd.read_sql_table("offshore_spur_costs_nrelatb", pudl_engine) spur_costs = spur_costs.loc[spur_costs["atb_year"] == settings["atb_data_year"], :] atb_target_year = settings["target_usd_year"] spur_costs["capex_mw"] = spur_costs.apply( lambda row: inflation_price_adjustment(row["capex_mw"], base_year=row["dollar_year"], target_year=atb_target_year), axis=1, ) # ATB assumes a 30km distance for offshore spur. Normalize to per mile spur_costs["capex_mw_mile"] = spur_costs["capex_mw"] / 30 * 1.60934 return spur_costs
def fetch_atb_offshore_spur_costs(pudl_engine, settings): """Load offshore spur-line costs and convert to desired dollar-year. Parameters ---------- pudl_engine : sqlalchemy.Engine A sqlalchemy connection for use by pandas settings : dict User-defined parameters from a settings file Returns ------- DataFrame Total offshore spur line capex from ATB for each technology/tech_detail/ basis_year/cost_case combination. """ spur_costs = pd.read_sql_table("offshore_spur_costs_nrelatb", pudl_engine) atb_base_year = settings["atb_usd_year"] atb_target_year = settings["target_usd_year"] spur_costs.loc[:, "capex"] = inflation_price_adjustment( price=spur_costs.loc[:, "capex"], base_year=atb_base_year, target_year=atb_target_year, ) # ATB assumes a 30km distance for offshore spur. Normalize to per mile spur_costs["capex_mw_mile"] = spur_costs["capex"] / 30 * 1.60934 return spur_costs
def fetch_atb_costs(pudl_engine, settings): """Get NREL ATB power plant cost data from database, filter where applicable Parameters ---------- pudl_engine : sqlalchemy.Engine A sqlalchemy connection for use by pandas settings : dict User-defined parameters from a settings file Returns ------- DataFrame Power plant cost data with columns: ['technology', 'cap_recovery_years', 'cost_case', 'financial_case', 'basis_year', 'tech_detail', 'o_m_fixed_mw', 'o_m_variable_mwh', 'capex', 'cf', 'fuel', 'lcoe', 'o_m', 'waccnomtech'] """ logger.info("Loading NREL ATB data") atb_costs = pd.read_sql_table("technology_costs_nrelatb", pudl_engine) index_cols = [ "technology", "cap_recovery_years", "cost_case", "financial_case", "basis_year", "tech_detail", ] atb_costs.set_index(index_cols, inplace=True) atb_costs.drop(columns=["key", "id"], inplace=True) cap_recovery = str(settings["atb_cap_recovery_years"]) financial = settings["atb_financial_case"] atb_costs = atb_costs.loc[idx[:, cap_recovery, :, financial, :, :], :] atb_costs = atb_costs.reset_index() atb_base_year = settings["atb_usd_year"] atb_target_year = settings["target_usd_year"] usd_columns = ["o_m_fixed_mw", "o_m_variable_mwh", "capex"] logger.info( f"Changing NREL ATB costs from {atb_base_year} to {atb_target_year} USD" ) atb_costs.loc[:, usd_columns] = inflation_price_adjustment( price=atb_costs.loc[:, usd_columns], base_year=atb_base_year, target_year=atb_target_year, ) return atb_costs
def fetch_fuel_prices(settings): API_KEY = SETTINGS["EIA_API_KEY"] aeo_year = settings["eia_aeo_year"] fuel_price_cases = product( settings["eia_series_region_names"].items(), settings["eia_series_fuel_names"].items(), settings["eia_series_scenario_names"].items(), ) df_list = [] for region, fuel, scenario in fuel_price_cases: region_name, region_series = region fuel_name, fuel_series = fuel scenario_name, scenario_series = scenario SERIES_ID = f"AEO.{aeo_year}.{scenario_series}.PRCE_REAL_ELEP_NA_{fuel_series}_NA_{region_series}_Y13DLRPMMBTU.A" url = f"http://api.eia.gov/series/?series_id={SERIES_ID}&api_key={API_KEY}&out=json" r = requests.get(url) try: df = pd.DataFrame(r.json()["series"][0]["data"], columns=["year", "price"]) except KeyError: print( "There was an error getting EIA AEO fuel price data.\n" f"Your requested url was {url}, make sure it looks right." ) df["fuel"] = fuel_name df["region"] = region_name df["scenario"] = scenario_name df["full_fuel_name"] = df.region + "_" + df.scenario + "_" + df.fuel df["year"] = df["year"].astype(int) df_list.append(df) final = pd.concat(df_list, ignore_index=True) fuel_price_base_year = settings["aeo_fuel_usd_year"] fuel_price_target_year = settings["target_usd_year"] final.loc[:, "price"] = inflation_price_adjustment( price=final.loc[:, "price"], base_year=fuel_price_base_year, target_year=fuel_price_target_year, ) return final
"lower_midwest": 3800, "miso_s": 3900 * 2.25, "great_lakes": 4100, "pjm_s": 3900 * 2.25, "pj_pa": 3900 * 2.25, "pjm_md_nj": 3900 * 2.25, "ny": 3900 * 2.25, "tva": 3800, "south": 4950, "fl": 4100, "vaca": 3800, "ne": 3900 * 2.25, } spur_costs_2017 = { region: inflation_price_adjustment(cost, 2013, ATB_USD_YEAR) for region, cost in spur_costs_2013.items() } tx_costs_2013 = { "wecc": 1350, "ca": 1350 * 2.25, # According to Reeds docs, CA is 2.25x the rest of WECC "tx": 1350, "upper_midwest": 900, "lower_midwest": 900, "miso_s": 1750, "great_lakes": 1050, "pjm_s": 1350, "pj_pa": 1750, "pjm_md_nj": 4250, # Bins are $1500 wide - assume max bin is $750 above max
def atb_fixed_var_om_existing(results, atb_costs_df, atb_hr_df, settings): """Add fixed and variable O&M for existing power plants ATB O&M data for new power plants are used as reference values. Fixed and variable O&M for each technology and heat rate are calculated. Assume that O&M scales with heat rate from new plants to existing generators. A separate multiplier for fixed O&M is specified in the settings file. Parameters ---------- results : DataFrame Compiled results of clustered power plants with weighted average heat rates. Note that column names should include "technology", "Heat_rate_MMBTU_per_MWh", and "region". Technology names should not yet be converted to snake case. atb_costs_df : DataFrame Cost data from NREL ATB atb_hr_df : DataFrame Heat rate data from NREL ATB settings : dict User-defined parameters from a settings file Returns ------- DataFrame Same as incoming "results" dataframe but with new columns "Fixed_OM_cost_per_MWyr" and "Var_OM_cost_per_MWh" """ logger.info("Adding fixed and variable O&M for existing plants") techs = settings["eia_atb_tech_map"] existing_year = settings["atb_existing_year"] # ATB string is <technology>_<tech_detail> techs = { eia: atb_costs_df.split("_") for eia, atb_costs_df in techs.items() } df_list = [] grouped_results = results.reset_index().groupby( ["technology", "Heat_rate_MMBTU_per_MWh"], as_index=False) for group, _df in grouped_results: eia_tech, existing_hr = group try: atb_tech, tech_detail = techs[eia_tech] except KeyError as e: if eia_tech in settings["tech_groups"].keys(): raise KeyError( f"{eia_tech} is defined in 'tech_groups' but doesn't have a " "corresponding ATB technology in 'eia_atb_tech_map'") else: raise KeyError( f"{eia_tech} doesn't have a corresponding ATB technology in " "'eia_atb_tech_map'") try: new_build_hr = (atb_hr_df.query( "technology==@atb_tech & tech_detail==@tech_detail" "& basis_year==@existing_year").squeeze().at["heat_rate"]) except ValueError: # Not all technologies have a heat rate. If they don't, just set both values # to 1 existing_hr = 1 new_build_hr = 1 if ("Natural Gas Fired" in eia_tech or "Coal" in eia_tech) and settings["use_nems_coal_ng_om"]: # Change CC and CT O&M to EIA NEMS values, which are much higher for CCs and # lower for CTs than a heat rate & linear mulitpler correction to the ATB # values. # Add natural gas steam turbine O&M. # Also using the new values for coal plants, assuming 40-50 yr age and half # FGD # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf logger.info(f"Using NEMS values for {eia_tech} fixed/variable O&M") target_usd_year = settings["target_usd_year"] ng_o_m = { "Combined Cycle": { "o_m_fixed_mw": inflation_price_adjustment(13.08 * 1000, 2017, target_usd_year), "o_m_variable_mwh": inflation_price_adjustment(3.91, 2017, target_usd_year), }, "Combustion Turbine": { # This includes both the Fixed O&M and Capex. Capex includes # variable O&M, which is split out in the calculations below. "o_m_fixed_mw": inflation_price_adjustment((5.33 + 6.90) * 1000, 2017, target_usd_year), "o_m_variable_mwh": 0, }, "Natural Gas Steam Turbine": { # NEMS documenation splits capex and fixed O&M across 2 tables "o_m_fixed_mw": inflation_price_adjustment((15.96 + 24.68) * 1000, 2017, target_usd_year), "o_m_variable_mwh": 1.0, }, "Coal": { "o_m_fixed_mw": inflation_price_adjustment( ((22.2 + 27.88) / 2 + 46.01) * 1000, 2017, target_usd_year), # This variable O&M is ignored. It's the value in NEMS but we think # that it is too low. ATB new coal has $5/MWh "o_m_variable_mwh": inflation_price_adjustment(1.78, 2017, target_usd_year), }, } if "Combined Cycle" in eia_tech: fixed = ng_o_m["Combined Cycle"]["o_m_fixed_mw"] variable = ng_o_m["Combined Cycle"]["o_m_variable_mwh"] _df["Fixed_OM_cost_per_MWyr"] = fixed _df["Var_OM_cost_per_MWh"] = variable if "Combustion Turbine" in eia_tech: # need to adjust the EIA fixed/variable costs because they have no # variable cost per MWh for existing CTs but they do have per MWh for # new build. Assume $11/MWh from new-build and 4% CF: # (11*8760*0.04/1000)=$3.85/kW-yr. Scale the new-build variable # (~$11/MWh) by relative heat rate and subtract a /kW-yr value as # calculated above from the FOM. # Based on conversation with Jesse J. on Dec 20, 2019. op, op_value = settings["atb_modifiers"]["ngct"][ "Var_OM_cost_per_MWh"] f = operator.attrgetter(op) atb_var_om_mwh = f(operator)( atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ).squeeze().at["o_m_variable_mwh"], op_value # * settings["atb_modifiers"]["ngct"]["Var_OM_cost_per_MWh"] ) variable = atb_var_om_mwh * (existing_hr / new_build_hr) fixed = ng_o_m["Combustion Turbine"]["o_m_fixed_mw"] fixed = fixed - (variable * 8760 * 0.04) _df["Fixed_OM_cost_per_MWyr"] = fixed _df["Var_OM_cost_per_MWh"] = variable if "Natural Gas Steam Turbine" in eia_tech: fixed = ng_o_m["Natural Gas Steam Turbine"]["o_m_fixed_mw"] variable = ng_o_m["Natural Gas Steam Turbine"][ "o_m_variable_mwh"] _df["Fixed_OM_cost_per_MWyr"] = fixed _df["Var_OM_cost_per_MWh"] = variable if "Coal" in eia_tech: # Doing a similar Variable O&M calculation to combustion turbines # because the EIA/NEMS value of $1.78/MWh is so much lower than the ATB # value of $5/MWh. # Assume 59% CF from NEMS documentation # Based on conversation with Jesse J. on Jan 10, 2020. atb_var_om_mwh = (atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ).squeeze().at["o_m_variable_mwh"]) variable = atb_var_om_mwh * (existing_hr / new_build_hr) fixed = ng_o_m["Coal"]["o_m_fixed_mw"] fixed = fixed - (variable * 8760 * 0.59) _df["Fixed_OM_cost_per_MWyr"] = fixed _df["Var_OM_cost_per_MWh"] = variable else: atb_fixed_om_mw_yr = (atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year"). squeeze().at["o_m_fixed_mw"]) atb_var_om_mwh = (atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year"). squeeze().at["o_m_variable_mwh"]) _df["Fixed_OM_cost_per_MWyr"] = ( atb_fixed_om_mw_yr * settings["existing_om_multiplier"] * (existing_hr / new_build_hr)) _df["Var_OM_cost_per_MWh"] = atb_var_om_mwh * (existing_hr / new_build_hr) df_list.append(_df) mod_results = pd.concat(df_list, ignore_index=True) mod_results = mod_results.sort_values(["region", "technology", "cluster"]) mod_results.loc[:, "Fixed_OM_cost_per_MWyr"] = mod_results.loc[:, "Fixed_OM_cost_per_MWyr"].astype( int) mod_results.loc[:, "Var_OM_cost_per_MWh"] = mod_results.loc[:, "Var_OM_cost_per_MWh"].round( 1) return mod_results
def atb_fixed_var_om_existing(results, atb_costs_df, atb_hr_df, settings): """Add fixed and variable O&M for existing power plants ATB O&M data for new power plants are used as reference values. Fixed and variable O&M for each technology and heat rate are calculated. Assume that O&M scales with heat rate from new plants to existing generators. A separate multiplier for fixed O&M is specified in the settings file. Parameters ---------- results : DataFrame Compiled results of clustered power plants with weighted average heat rates. Note that column names should include "technology", "Heat_rate_MMBTU_per_MWh", and "region". Technology names should not yet be converted to snake case. atb_costs_df : DataFrame Cost data from NREL ATB atb_hr_df : DataFrame Heat rate data from NREL ATB settings : dict User-defined parameters from a settings file Returns ------- DataFrame Same as incoming "results" dataframe but with new columns "Fixed_OM_cost_per_MWyr" and "Var_OM_cost_per_MWh" """ logger.info("Adding fixed and variable O&M for existing plants") techs = settings["eia_atb_tech_map"] existing_year = settings["atb_existing_year"] # ATB string is <technology>_<tech_detail> techs = {eia: atb_costs_df.split("_") for eia, atb_costs_df in techs.items()} df_list = [] grouped_results = results.reset_index().groupby( ["plant_id_eia", "technology"], as_index=False ) for group, _df in grouped_results: plant_id, eia_tech = group existing_hr = _df["heat_rate_mmbtu_mwh"].mean() try: atb_tech, tech_detail = techs[eia_tech] except KeyError: if eia_tech in settings["tech_groups"]: raise KeyError( f"{eia_tech} is defined in 'tech_groups' but doesn't have a " "corresponding ATB technology in 'eia_atb_tech_map'" ) else: raise KeyError( f"{eia_tech} doesn't have a corresponding ATB technology in " "'eia_atb_tech_map'" ) try: new_build_hr = ( atb_hr_df.query( "technology==@atb_tech & tech_detail==@tech_detail" "& basis_year==@existing_year" ) .squeeze() .at["heat_rate"] ) except ValueError: # Not all technologies have a heat rate. If they don't, just set both values # to 1 existing_hr = 1 new_build_hr = 1 nems_o_m_techs = [ "Combined Cycle", "Combustion Turbine", "Coal", "Steam Turbine", "Hydroelectric", "Geothermal", ] if any(t in eia_tech for t in nems_o_m_techs): # Change CC and CT O&M to EIA NEMS values, which are much higher for CCs and # lower for CTs than a heat rate & linear mulitpler correction to the ATB # values. # Add natural gas steam turbine O&M. # Also using the new values for coal plants, assuming 40-50 yr age and half # FGD # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf # logger.info(f"Using NEMS values for {eia_tech} fixed/variable O&M") target_usd_year = settings["target_usd_year"] simple_o_m = { "Combined Cycle": { # "o_m_fixed_mw": inflation_price_adjustment( # 13.08 * 1000, 2017, target_usd_year # ), # "o_m_variable_mwh": inflation_price_adjustment( # 3.91, 2017, target_usd_year # ) }, # "Combustion Turbine": { # # This includes both the Fixed O&M and Capex. Capex includes # # variable O&M, which is split out in the calculations below. # "o_m_fixed_mw": inflation_price_adjustment( # (5.33 + 6.90) * 1000, 2017, target_usd_year # ), # "o_m_variable_mwh": 0, # }, "Natural Gas Steam Turbine": { # NEMS documenation splits capex and fixed O&M across 2 tables # "o_m_fixed_mw": inflation_price_adjustment( # (15.96 + 24.68) * 1000, 2017, target_usd_year # ), "o_m_variable_mwh": inflation_price_adjustment( 1.0, 2017, target_usd_year ) }, "Coal": { # "o_m_fixed_mw": inflation_price_adjustment( # ((22.2 + 27.88) / 2 + 46.01) * 1000, 2017, target_usd_year # ), "o_m_variable_mwh": inflation_price_adjustment( 1.78, 2017, target_usd_year ) }, "Conventional Hydroelectric": { "o_m_fixed_mw": inflation_price_adjustment( 44.56 * 1000, 2017, target_usd_year ), "o_m_variable_mwh": 0, }, "Geothermal": { "o_m_fixed_mw": inflation_price_adjustment( 198.04 * 1000, 2017, target_usd_year ), "o_m_variable_mwh": 0, }, "Pumped Hydro": { "o_m_fixed_mw": inflation_price_adjustment( (23.63 + 14.83) * 1000, 2017, target_usd_year ), "o_m_variable_mwh": 0, }, } if "Combined Cycle" in eia_tech: # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 if plant_capacity < 500: fixed = 15.62 * 1000 variable = 4.31 elif 500 <= plant_capacity < 1000: fixed = 9.27 * 1000 variable = 3.42 else: fixed = 11.68 * 1000 variable = 3.37 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year ) _df["Var_OM_cost_per_MWh"] = inflation_price_adjustment( variable, 2017, target_usd_year ) if "Combustion Turbine" in eia_tech: # need to adjust the EIA fixed/variable costs because they have no # variable cost per MWh for existing CTs but they do have per MWh for # new build. Assume $11/MWh from new-build and 4% CF: # (11*8760*0.04/1000)=$3.85/kW-yr. Scale the new-build variable # (~$11/MWh) by relative heat rate and subtract a /kW-yr value as # calculated above from the FOM. # Based on conversation with Jesse J. on Dec 20, 2019. plant_capacity = _df[settings["capacity_col"]].sum() op, op_value = ( settings.get("atb_modifiers", {}) .get("ngct", {}) .get("Var_OM_cost_per_MWh", (None, None)) ) # Variable O&M for new-build atb_var_om_mwh = ( atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ) .squeeze() .at["o_m_variable_mwh"] ) if op: f = operator.attrgetter(op) atb_var_om_mwh = f(operator)(atb_var_om_mwh, op_value) variable = atb_var_om_mwh # * (existing_hr / new_build_hr) if plant_capacity < 100: annual_capex = 9.0 * 1000 fixed = annual_capex + 5.96 * 1000 elif 100 <= plant_capacity <= 300: annual_capex = 6.18 * 1000 fixed = annual_capex + 6.43 * 1000 else: annual_capex = 6.95 * 1000 fixed = annual_capex + 3.99 * 1000 fixed = fixed - (variable * 8760 * 0.04) _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year ) _df["Var_OM_cost_per_MWh"] = inflation_price_adjustment( variable, 2017, target_usd_year ) if "Natural Gas Steam Turbine" in eia_tech: # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 if plant_capacity < 500: annual_capex = 18.86 * 1000 fixed = annual_capex + 29.73 * 1000 elif 500 <= plant_capacity < 1000: annual_capex = 11.57 * 1000 fixed = annual_capex + 17.98 * 1000 else: annual_capex = 10.82 * 1000 fixed = annual_capex + 14.51 * 1000 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year ) _df["Var_OM_cost_per_MWh"] = simple_o_m["Natural Gas Steam Turbine"][ "o_m_variable_mwh" ] if "Coal" in eia_tech: plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 atb_var_om_mwh = ( atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ) .squeeze() .at["o_m_variable_mwh"] ) age = settings["model_year"] - _df.operating_date.dt.year # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf annual_capex = (16.53 + (0.126 * age) + (5.68 * 0.5)) * 1000 if plant_capacity < 500: fixed = 44.21 * 1000 elif 500 <= plant_capacity < 1000: fixed = 34.02 * 1000 elif 1000 <= plant_capacity < 2000: fixed = 28.52 * 1000 else: fixed = 33.27 * 1000 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed + annual_capex, 2017, target_usd_year ) _df["Var_OM_cost_per_MWh"] = simple_o_m["Coal"]["o_m_variable_mwh"] if "Hydroelectric" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m[ "Conventional Hydroelectric" ]["o_m_fixed_mw"] _df["Var_OM_cost_per_MWh"] = simple_o_m["Conventional Hydroelectric"][ "o_m_variable_mwh" ] if "Geothermal" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m["Geothermal"]["o_m_fixed_mw"] _df["Var_OM_cost_per_MWh"] = simple_o_m["Geothermal"][ "o_m_variable_mwh" ] if "Pumped" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m["Pumped Hydro"][ "o_m_fixed_mw" ] _df["Var_OM_cost_per_MWh"] = simple_o_m["Pumped Hydro"][ "o_m_variable_mwh" ] else: atb_fixed_om_mw_yr = ( atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ) .squeeze() .at["o_m_fixed_mw"] ) atb_var_om_mwh = ( atb_costs_df.query( "technology==@atb_tech & cost_case=='Mid' " "& tech_detail==@tech_detail & basis_year==@existing_year" ) .squeeze() .at["o_m_variable_mwh"] ) _df["Fixed_OM_cost_per_MWyr"] = atb_fixed_om_mw_yr _df["Var_OM_cost_per_MWh"] = atb_var_om_mwh * (existing_hr / new_build_hr) df_list.append(_df) mod_results = pd.concat(df_list, ignore_index=True) # mod_results = mod_results.sort_values(["model_region", "technology", "cluster"]) mod_results.loc[:, "Fixed_OM_cost_per_MWyr"] = mod_results.loc[ :, "Fixed_OM_cost_per_MWyr" ].astype(int) mod_results.loc[:, "Var_OM_cost_per_MWh"] = mod_results.loc[ :, "Var_OM_cost_per_MWh" ].round(1) return mod_results
"lower_midwest": 3800, "miso_s": 3900 * 2.25, "great_lakes": 4100, "pjm_s": 3900 * 2.25, "pj_pa": 3900 * 2.25, "pjm_md_nj": 3900 * 2.25, "ny": 3900 * 2.25, "tva": 3800, "south": 4950, "fl": 4100, "vaca": 3800, "ne": 3900 * 2.25, } spur_costs_2017 = { region: inflation_price_adjustment(cost, 2013, 2017) for region, cost in spur_costs_2013.items() } tx_costs_2013 = { "wecc": 1350, "ca": 1350 * 2.25, # According to Reeds docs, CA is 2.25x the rest of WECC "tx": 1350, "upper_midwest": 900, "lower_midwest": 900, "miso_s": 1750, "great_lakes": 1050, "pjm_s": 1350, "pj_pa": 1750, "pjm_md_nj": 4250, # Bins are $1500 wide - assume max bin is $750 above max "ny": 2750,
def add_resource_max_cap_spur(new_resource_df, settings, capacity_col="Max_Cap_MW"): """Load user supplied maximum capacity and spur line data for new resources. Add those values to the resources dataframe. Parameters ---------- new_resource_df : DataFrame New resources that can be built. Each row should be a single resource, with columns 'region' and 'technology'. The number of copies for a region/resource (e.g. more than one UtilityPV resource in a region) should match what is given in the user-supplied file. settings : dict User-defined parameters from a settings file. Should have keys of `input_folder` (a Path object of where to find user-supplied data) and `capacity_limit_spur_fn` (the file to load). capacity_col : str, optional The column that indicates maximum capacity constraints, by default "Max_Cap_MW" Returns ------- DataFrame A modified version of new_resource_df with spur_miles and the maximum capacity for a resource. Copies of a resource within a region should have a cluster name to uniquely identify them. """ defaults = { "cluster": 1, "spur_miles": 0, capacity_col: -1, "interconnect_annuity": 0, } # Prepare file path = Path(settings["input_folder"]) / settings["capacity_limit_spur_fn"] df = pd.read_csv(path) for col in "region", "technology": if col not in df: raise KeyError( f"The max capacity/spur file must have column {col}") for key, value in defaults.items(): df[key] = df[key].fillna(value) if key in df else value new_resource_df[key] = (new_resource_df[key].fillna(value) if key in new_resource_df else value) # Update resources grouped_df = df.groupby(["region", "technology"]) for (region, tech), _df in grouped_df: mask = (new_resource_df["region"] == region) & ( new_resource_df["technology"].str.lower().str.contains( tech.lower())) if mask.sum() > 1: raise ValueError( f"Resource {tech} in region {region} from file " f"{settings['capacity_limit_spur_fn']} matches multiple resources" ) for key, value in defaults.items(): _key = "max_capacity" if key == capacity_col else key new_resource_df.loc[mask & (new_resource_df[key] == value), key] = _df[_key].values logger.info(f"Inflating external interconnect annuity costs from 2017 to " f"{settings['target_usd_year']}") new_resource_df["interconnect_annuity"] = inflation_price_adjustment( new_resource_df["interconnect_annuity"], 2017, settings["target_usd_year"], ) return new_resource_df
def atb_fixed_var_om_existing( results: pd.DataFrame, atb_hr_df: pd.DataFrame, settings: dict, pudl_engine: sqlalchemy.engine.base.Engine, ) -> pd.DataFrame: """Add fixed and variable O&M for existing power plants ATB O&M data for new power plants are used as reference values. Fixed and variable O&M for each technology and heat rate are calculated. Assume that O&M scales with heat rate from new plants to existing generators. A separate multiplier for fixed O&M is specified in the settings file. Parameters ---------- results : DataFrame Compiled results of clustered power plants with weighted average heat rates. Note that column names should include "technology", "Heat_rate_MMBTU_per_MWh", and "region". Technology names should not yet be converted to snake case. atb_hr_df : DataFrame Heat rate data from NREL ATB settings : dict User-defined parameters from a settings file Returns ------- DataFrame Same as incoming "results" dataframe but with new columns "Fixed_OM_cost_per_MWyr" and "Var_OM_cost_per_MWh" """ logger.info("Adding fixed and variable O&M for existing plants") techs = settings["eia_atb_tech_map"] existing_year = settings["atb_existing_year"] # ATB string is <technology>_<tech_detail> techs = {eia: atb.split("_") for eia, atb in techs.items()} df_list = [] grouped_results = results.reset_index().groupby( ["plant_id_eia", "technology"], as_index=False) for group, _df in grouped_results: plant_id, eia_tech = group existing_hr = _df["heat_rate_mmbtu_mwh"].mean() try: atb_tech, tech_detail = techs[eia_tech] except KeyError: if eia_tech in settings["tech_groups"]: raise KeyError( f"{eia_tech} is defined in 'tech_groups' but doesn't have a " "corresponding ATB technology in 'eia_atb_tech_map'") else: raise KeyError( f"{eia_tech} doesn't have a corresponding ATB technology in " "'eia_atb_tech_map'") try: new_build_hr = (atb_hr_df.query( "technology==@atb_tech & tech_detail==@tech_detail" "& basis_year==@existing_year").squeeze().at["heat_rate"]) except ValueError: # Not all technologies have a heat rate. If they don't, just set both values # to 1 existing_hr = 1 new_build_hr = 1 try: s = f""" select parameter_value from technology_costs_nrelatb where technology == "{atb_tech}" AND tech_detail == "{tech_detail}" AND basis_year == "{existing_year}" AND financial_case == "Market" AND cost_case == "Mid" AND atb_year == "{settings['atb_data_year']}" AND parameter == "variable_o_m_mwh" """ atb_var_om_mwh = pudl_engine.execute(s).fetchall()[0][0] except IndexError: # logger.warning(f"No variable O&M for {atb_tech}") atb_var_om_mwh = 0 try: s = f""" select parameter_value from technology_costs_nrelatb where technology == "{atb_tech}" AND tech_detail == "{tech_detail}" AND basis_year == "{existing_year}" AND financial_case == "Market" AND cost_case == "Mid" AND atb_year == "{settings['atb_data_year']}" AND parameter == "fixed_o_m_mw" """ atb_fixed_om_mw_yr = pudl_engine.execute(s).fetchall()[0][0] except IndexError: # logger.warning(f"No fixed O&M for {atb_tech}") atb_fixed_om_mw_yr = 0 nems_o_m_techs = [ "Combined Cycle", "Combustion Turbine", "Coal", "Steam Turbine", "Hydroelectric", "Geothermal", "Nuclear", ] if any(t in eia_tech for t in nems_o_m_techs): # Change CC and CT O&M to EIA NEMS values, which are much higher for CCs and # lower for CTs than a heat rate & linear mulitpler correction to the ATB # values. # Add natural gas steam turbine O&M. # Also using the new values for coal plants, assuming 40-50 yr age and half # FGD # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf # logger.info(f"Using NEMS values for {eia_tech} fixed/variable O&M") target_usd_year = settings["target_usd_year"] simple_o_m = { "Combined Cycle": { # "fixed_o_m_mw": inflation_price_adjustment( # 13.08 * 1000, 2017, target_usd_year # ), # "variable_o_m_mwh": inflation_price_adjustment( # 3.91, 2017, target_usd_year # ) }, # "Combustion Turbine": { # # This includes both the Fixed O&M and Capex. Capex includes # # variable O&M, which is split out in the calculations below. # "fixed_o_m_mw": inflation_price_adjustment( # (5.33 + 6.90) * 1000, 2017, target_usd_year # ), # "variable_o_m_mwh": 0, # }, "Natural Gas Steam Turbine": { # NEMS documenation splits capex and fixed O&M across 2 tables # "fixed_o_m_mw": inflation_price_adjustment( # (15.96 + 24.68) * 1000, 2017, target_usd_year # ), "variable_o_m_mwh": inflation_price_adjustment(1.0, 2017, target_usd_year) }, "Coal": { # "fixed_o_m_mw": inflation_price_adjustment( # ((22.2 + 27.88) / 2 + 46.01) * 1000, 2017, target_usd_year # ), "variable_o_m_mwh": inflation_price_adjustment(1.78, 2017, target_usd_year) }, "Conventional Hydroelectric": { "fixed_o_m_mw": inflation_price_adjustment(44.56 * 1000, 2017, target_usd_year), "variable_o_m_mwh": 0, }, "Geothermal": { "fixed_o_m_mw": inflation_price_adjustment(198.04 * 1000, 2017, target_usd_year), "variable_o_m_mwh": 0, }, "Pumped Hydro": { "fixed_o_m_mw": inflation_price_adjustment((23.63 + 14.83) * 1000, 2017, target_usd_year), "variable_o_m_mwh": 0, }, } if "Combined Cycle" in eia_tech: # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 if plant_capacity < 500: fixed = 15.62 * 1000 variable = 4.31 elif 500 <= plant_capacity < 1000: fixed = 9.27 * 1000 variable = 3.42 else: fixed = 11.68 * 1000 variable = 3.37 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year) _df["Var_OM_cost_per_MWh"] = inflation_price_adjustment( variable, 2017, target_usd_year) if "Combustion Turbine" in eia_tech: # need to adjust the EIA fixed/variable costs because they have no # variable cost per MWh for existing CTs but they do have per MWh for # new build. Assume $11/MWh from new-build and 4% CF: # (11*8760*0.04/1000)=$3.85/kW-yr. Scale the new-build variable # (~$11/MWh) by relative heat rate and subtract a /kW-yr value as # calculated above from the FOM. # Based on conversation with Jesse J. on Dec 20, 2019. plant_capacity = _df[settings["capacity_col"]].sum() op, op_value = (settings.get("atb_modifiers", {}).get( "ngct", {}).get("Var_OM_cost_per_MWh", (None, None))) if op: f = operator.attrgetter(op) atb_var_om_mwh = f(operator)(atb_var_om_mwh, op_value) variable = atb_var_om_mwh # * (existing_hr / new_build_hr) if plant_capacity < 100: annual_capex = 9.0 * 1000 fixed = annual_capex + 5.96 * 1000 elif 100 <= plant_capacity <= 300: annual_capex = 6.18 * 1000 fixed = annual_capex + 6.43 * 1000 else: annual_capex = 6.95 * 1000 fixed = annual_capex + 3.99 * 1000 fixed = fixed - (variable * 8760 * 0.04) _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year) _df["Var_OM_cost_per_MWh"] = inflation_price_adjustment( variable, 2017, target_usd_year) if "Natural Gas Steam Turbine" in eia_tech: # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 if plant_capacity < 500: annual_capex = 18.86 * 1000 fixed = annual_capex + 29.73 * 1000 elif 500 <= plant_capacity < 1000: annual_capex = 11.57 * 1000 fixed = annual_capex + 17.98 * 1000 else: annual_capex = 10.82 * 1000 fixed = annual_capex + 14.51 * 1000 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2017, target_usd_year) _df["Var_OM_cost_per_MWh"] = simple_o_m[ "Natural Gas Steam Turbine"]["variable_o_m_mwh"] if "Coal" in eia_tech: plant_capacity = _df[settings["capacity_col"]].sum() assert plant_capacity > 0 age = settings["model_year"] - _df.operating_date.dt.year age = age.fillna(age.mean()) age = age.fillna(40) # https://www.eia.gov/analysis/studies/powerplants/generationcost/pdf/full_report.pdf annual_capex = (16.53 + (0.126 * age) + (5.68 * 0.5)) * 1000 if plant_capacity < 500: fixed = 44.21 * 1000 elif 500 <= plant_capacity < 1000: fixed = 34.02 * 1000 elif 1000 <= plant_capacity < 2000: fixed = 28.52 * 1000 else: fixed = 33.27 * 1000 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed + annual_capex, 2017, target_usd_year) _df["Var_OM_cost_per_MWh"] = simple_o_m["Coal"][ "variable_o_m_mwh"] if "Hydroelectric" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m[ "Conventional Hydroelectric"]["fixed_o_m_mw"] _df["Var_OM_cost_per_MWh"] = simple_o_m[ "Conventional Hydroelectric"]["variable_o_m_mwh"] if "Geothermal" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m["Geothermal"][ "fixed_o_m_mw"] _df["Var_OM_cost_per_MWh"] = simple_o_m["Geothermal"][ "variable_o_m_mwh"] if "Pumped" in eia_tech: _df["Fixed_OM_cost_per_MWyr"] = simple_o_m["Pumped Hydro"][ "fixed_o_m_mw"] _df["Var_OM_cost_per_MWh"] = simple_o_m["Pumped Hydro"][ "variable_o_m_mwh"] if "Nuclear" in eia_tech: num_units = len(_df) plant_capacity = _df[settings["capacity_col"]].sum() # Operating costs for different size/num units in 2016 INL report # "Economic and Market Challenges Facing the U.S. Nuclear Fleet" # https://gain.inl.gov/Shared%20Documents/Economics-Nuclear-Fleet.pdf, # table 1. Average of the two costs are used in each case. # The costs in that report include fuel and VOM. Assume $0.66/mmbtu # and $2.32/MWh plus 90% CF (ATB 2020) to get the costs below. # The INL report doesn't give a dollar year for costs, assume 2015. if num_units == 1 and plant_capacity < 900: fixed = 315000 elif num_units == 1 and plant_capacity >= 900: fixed = 252000 else: fixed = 177000 # age = (settings["model_year"] - _df.operating_date.dt.year).values # age = age.fillna(age.mean()) # age = age.fillna(40) # EIA, 2020, "Assumptions to Annual Energy Outlook, Electricity Market Module," # Available: https://www.eia.gov/outlooks/aeo/assumptions/pdf/electricity.pdf # fixed = np.ones_like(age) # fixed[age < 30] *= 27 * 1000 # fixed[age >= 30] *= (27+37) * 1000 _df["Fixed_OM_cost_per_MWyr"] = inflation_price_adjustment( fixed, 2015, target_usd_year) _df["Var_OM_cost_per_MWh"] = atb_var_om_mwh * (existing_hr / new_build_hr) else: _df["Fixed_OM_cost_per_MWyr"] = atb_fixed_om_mw_yr _df["Var_OM_cost_per_MWh"] = atb_var_om_mwh * (existing_hr / new_build_hr) df_list.append(_df) mod_results = pd.concat(df_list, ignore_index=True) # mod_results = mod_results.sort_values(["model_region", "technology", "cluster"]) mod_results.loc[:, "Fixed_OM_cost_per_MWyr"] = mod_results.loc[:, "Fixed_OM_cost_per_MWyr"].astype( int) mod_results.loc[:, "Var_OM_cost_per_MWh"] = mod_results.loc[:, "Var_OM_cost_per_MWh"] return mod_results
def fetch_atb_costs( pudl_engine: sqlalchemy.engine.base.Engine, settings: dict, offshore_spur_costs: pd.DataFrame = None, ) -> pd.DataFrame: """Get NREL ATB power plant cost data from database, filter where applicable. This function can also remove NREL ATB offshore spur costs if more accurate costs will be included elsewhere (e.g. as part of total interconnection costs). Parameters ---------- pudl_engine : sqlalchemy.Engine A sqlalchemy connection for use by pandas settings : dict User-defined parameters from a settings file. Needs to have keys `atb_data_year`, `atb_new_gen`, and `target_usd_year`. If the key `atb_financial_case` is not included, the default value will be "Market". offshore_spur_costs : pd.DataFrame An optional dataframe with spur costs for offshore wind resources. These costs are included in ATB for a fixed distance (same for all sites). PowerGenome interconnection costs for offshore sites include a spur cost calculated using actual distance from shore. Returns ------- pd.DataFrame Power plant cost data with columns: ['technology', 'cap_recovery_years', 'cost_case', 'financial_case', 'basis_year', 'tech_detail', 'fixed_o_m_mw', 'variable_o_m_mwh', 'capex', 'cf', 'fuel', 'lcoe', 'wacc_nominal'] """ logger.info("Loading NREL ATB data") col_names = [ "technology", "tech_detail", "cost_case", "parameter", "basis_year", "parameter_value", "dollar_year", ] atb_year = settings["atb_data_year"] fin_case = settings.get("atb_financial_case", "Market") # Fetch cost data from sqlite and create dataframe. Only get values for techs/cases # listed in the settings file. all_rows = [] wacc_rows = [] tech_list = [] techs = settings["atb_new_gen"] mod_techs = [] if settings.get("modified_atb_new_gen"): for _, m in settings.get("modified_atb_new_gen").items(): mod_techs.append([ m["atb_technology"], m["atb_tech_detail"], m["atb_cost_case"], None ]) cost_params = ( "capex_mw", "fixed_o_m_mw", "variable_o_m_mwh", "capex_mwh", "fixed_o_m_mwh", ) # add_pv_wacc = True for tech in techs + mod_techs: tech, tech_detail, cost_case, _ = tech # if tech == "UtilityPV": # add_pv_wacc = False s = f""" SELECT technology, tech_detail, cost_case, parameter, basis_year, parameter_value, dollar_year from technology_costs_nrelatb where technology == "{tech}" AND tech_detail == "{tech_detail}" AND financial_case == "{fin_case}" AND cost_case == "{cost_case}" AND atb_year == {atb_year} AND parameter IN ({','.join('?'*len(cost_params))}) """ all_rows.extend(pudl_engine.execute(s, cost_params).fetchall()) if tech not in tech_list: # ATB2020 summary file provides a single WACC for each technology and a single # tech detail of "*", so need to fetch this separately from other cost params. # Only need to fetch once per technology. wacc_s = f""" select technology, cost_case, basis_year, parameter_value from technology_costs_nrelatb where technology == "{tech}" AND financial_case == "{fin_case}" AND cost_case == "{cost_case}" AND atb_year == {atb_year} AND parameter == "wacc_nominal" """ wacc_rows.extend(pudl_engine.execute(wacc_s).fetchall()) tech_list.append(tech) if "Battery" not in tech_list: df = pd.DataFrame(all_rows, columns=col_names) wacc_df = pd.DataFrame( wacc_rows, columns=["technology", "cost_case", "basis_year", "wacc_nominal"]) else: # ATB doesn't have a WACC for battery storage. We use UtilityPV WACC as a default # stand-in -- make sure we have it in case. s = 'SELECT DISTINCT("technology") from technology_costs_nrelatb WHERE parameter == "wacc_nominal"' atb_techs = [x[0] for x in pudl_engine.execute(s).fetchall()] battery_wacc_standin = settings.get("atb_battery_wacc") battery_tech = [x for x in techs if x[0] == "Battery"][0] if isinstance(battery_wacc_standin, float): if battery_wacc_standin > 0.1: logger.warning( f"You defined a battery WACC of {battery_wacc_standin}, which seems" " very high. Check settings parameter `atb_battery_wacc`.") battery_wacc_rows = [(battery_tech[0], battery_tech[2], year, battery_wacc_standin) for year in range(2017, 2051)] wacc_rows.extend(battery_wacc_rows) elif battery_wacc_standin in atb_techs: # if battery_wacc_standin in tech_list: # pass # else: logger.info( f"Using {battery_wacc_standin} {fin_case} WACC for Battery storage." ) wacc_s = f""" select technology, cost_case, basis_year, parameter_value from technology_costs_nrelatb where technology == "{battery_wacc_standin}" AND financial_case == "{fin_case}" AND cost_case == "Mid" AND atb_year == {atb_year} AND parameter == "wacc_nominal" """ b_rows = pudl_engine.execute(wacc_s).fetchall() battery_wacc_rows = [(battery_tech[0], battery_tech[2], b_row[2], b_row[3]) for b_row in b_rows] wacc_rows.extend(battery_wacc_rows) else: raise ValueError( f"The settings key `atb_battery_wacc` value is {battery_wacc_standin}. It " f"should either be a float or a string from the list {atb_techs}." ) df = pd.DataFrame(all_rows, columns=col_names) wacc_df = pd.DataFrame( wacc_rows, columns=["technology", "cost_case", "basis_year", "wacc_nominal"]) # Transform from tidy to wide dataframe, which makes it easier to fill generator # rows with the correct values. atb_costs = (df.drop_duplicates().set_index([ "technology", "tech_detail", "cost_case", "dollar_year", "basis_year", "parameter", ]).unstack(level=-1)) atb_costs.columns = atb_costs.columns.droplevel(0) atb_costs = (atb_costs.reset_index().merge( wacc_df, on=["technology", "cost_case", "basis_year"], how="left").drop_duplicates()) atb_costs = atb_costs.fillna(0) usd_columns = [ "fixed_o_m_mw", "fixed_o_m_mwh", "variable_o_m_mwh", "capex_mw", "capex_mwh", ] for col in usd_columns: if col not in atb_costs.columns: atb_costs[col] = 0 atb_target_year = settings["target_usd_year"] atb_costs[usd_columns] = atb_costs.apply( lambda row: inflation_price_adjustment(row[usd_columns], base_year=row["dollar_year"], target_year=atb_target_year), axis=1, ) if any("PV" in tech for tech in tech_list) and atb_year == 2019: print("Inflating ATB 2019 PV costs from DC to AC") atb_costs.loc[atb_costs["technology"].str.contains("PV"), ["capex_mw", "fixed_o_m_mw", "variable_o_m_mwh"], ] *= settings.get("pv_ac_dc_ratio", 1.3) elif atb_year > 2019: logger.info( "PV costs are already in AC units, not inflating the cost.") if offshore_spur_costs is not None and "OffShoreWind" in atb_costs[ "technology"]: idx_cols = ["technology", "tech_detail", "cost_case", "basis_year"] offshore_spur_costs = offshore_spur_costs.set_index(idx_cols) atb_costs = atb_costs.set_index(idx_cols) atb_costs.loc[idx["OffShoreWind", :, :, :], "capex_mw"] = ( atb_costs.loc[idx["OffShoreWind", :, :, :], "capex_mw"] # .values - offshore_spur_costs.loc[idx["OffShoreWind", :, :, :], "capex_mw"] # .values ) atb_costs = atb_costs.reset_index() return atb_costs