diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst index 04fa505311..14ccdb30d2 100644 --- a/doc/whatsnew.rst +++ b/doc/whatsnew.rst @@ -1,8 +1,15 @@ What's new ********** -.. Next release -.. ============ +Next release +============ + +Changes to :doc:`/api/tools-costs` (:pull:`186`) +------------------------------------------------ + - Fix jumps in cost projections for technologies with first technology year that's after than the first model year. + - Change the use of base_year to mean the year to start modeling cost changes. + - Update cost assumptions for certain CCS technologies. + - Change the default fixed O&M reduction rate to 0. v2024.4.22 ========== diff --git a/message_ix_models/data/costs/energy/cost_reduction_energy.csv b/message_ix_models/data/costs/energy/cost_reduction_energy.csv index 8ef88ddb71..e6994a13eb 100644 --- a/message_ix_models/data/costs/energy/cost_reduction_energy.csv +++ b/message_ix_models/data/costs/energy/cost_reduction_energy.csv @@ -21,12 +21,12 @@ gas_cc_ccs,CCS,0.1,0.2,0.29,0.5,0.7 bio_istig_ccs,CCS,0,0.1,0.3,0.4,0.6 syn_liq,Coal,0,0.05,0.1,0.15,0.2 meth_coal,Coal,0,0.05,0.1,0.15,0.2 -syn_liq_ccs,CCS,0,0.05,0.1,0.25,0.3 +syn_liq_ccs,CCS,0,0.05,0.1,0.15,0.2 meth_coal_ccs,CCS,0,0.05,0.1,0.15,0.2 h2_coal,Coal,0,0.25,0.4,0.4,0.5 h2_smr,Gas/Oil,0,0.25,0.4,0.5,0.7 h2_bio,Biomass,0,0.25,0.4,0.5,0.7 -h2_coal_ccs,CCS,0,0.25,0.4,0.5,0.7 +h2_coal_ccs,CCS,0,0.25,0.4,0.4,0.5 h2_smr_ccs,CCS,0,0.25,0.4,0.5,0.7 h2_bio_ccs,CCS,0,0.25,0.4,0.5,0.7 eth_bio,Biomass,0,0.27,0.27,0.4,0.55 diff --git a/message_ix_models/data/costs/energy/scenarios_reduction_energy.csv b/message_ix_models/data/costs/energy/scenarios_reduction_energy.csv index 67db271b2b..dd24f65e08 100644 --- a/message_ix_models/data/costs/energy/scenarios_reduction_energy.csv +++ b/message_ix_models/data/costs/energy/scenarios_reduction_energy.csv @@ -13,14 +13,14 @@ gas_cc_ccs,medium,medium,low,high,high,medium bio_istig_ccs,medium,medium,low,high,high,medium syn_liq,medium,medium,high,medium,medium,medium meth_coal,medium,medium,high,medium,medium,medium -syn_liq_ccs,medium,medium,low,high,high,medium -meth_coal_ccs,medium,medium,low,high,high,medium +syn_liq_ccs,medium,medium,high,medium,medium,medium +meth_coal_ccs,medium,medium,high,medium,medium,medium h2_coal,medium,medium,high,medium,medium,medium h2_smr,high,medium,low,medium,high,medium h2_bio,high,medium,low,high,medium,medium -h2_coal_ccs,medium,medium,low,high,high,medium +h2_coal_ccs,medium,medium,high,medium,medium,medium h2_smr_ccs,medium,medium,low,high,high,medium -h2_bio_ccs,medium,medium,low,high,high,medium +h2_bio_ccs,high,medium,low,high,medium,medium eth_bio,high,medium,low,high,medium,medium eth_bio_ccs,medium,medium,low,high,high,medium c_ppl_co2scr,medium,medium,low,high,high,medium @@ -36,12 +36,12 @@ geo_ppl,high,medium,low,high,medium,medium hydro_lc,high,medium,low,high,medium,medium hydro_hc,high,medium,low,high,medium,medium meth_ng,high,medium,low,medium,high,medium -meth_ng_ccs,medium,medium,low,high,high,medium +meth_ng_ccs,high,medium,low,medium,high,medium coal_ppl_u,medium,medium,high,medium,medium,medium stor_ppl,high,medium,low,high,medium,medium h2_elec,high,medium,low,high,medium,medium liq_bio,high,medium,low,high,medium,medium -liq_bio_ccs,medium,medium,low,high,high,medium +liq_bio_ccs,high,medium,low,high,medium,medium coal_i,medium,medium,high,medium,medium,medium foil_i,high,medium,low,medium,high,medium loil_i,high,medium,low,medium,high,medium diff --git a/message_ix_models/data/costs/energy/tech_map_energy.csv b/message_ix_models/data/costs/energy/tech_map_energy.csv index fb85741950..0da89811f2 100644 --- a/message_ix_models/data/costs/energy/tech_map_energy.csv +++ b/message_ix_models/data/costs/energy/tech_map_energy.csv @@ -1,6 +1,6 @@ # The base year costs and WEO mappings are taken from the following file: ,,,, # https://github.com/iiasa/message_data/blob/dev/data/model/investment_cost/doc/NAM_technology_cost_input_20200507.xlsx,,,, -# If base_year_reference_region_cost is blank then the 2021 WEO cost from the mapped technology is used in its place. +# If base_year_reference_region_cost is blank then the 2021 WEO cost from the mapped technology is used in its place.,,,, message_technology,reg_diff_source,reg_diff_technology,base_year_reference_region_cost,fix_ratio bio_hpl,weo,igcc,275, bio_istig,weo,igcc,4064, @@ -36,9 +36,9 @@ gas_ppl,weo,gas_turbine,1205, geo_hpl,weo,geothermal,1500, geo_ppl,weo,geothermal,2928, h2_bio,weo,igcc,3744, -h2_bio_ccs,weo,igcc_ccs,3824, +h2_bio_ccs,weo,igcc,3824, h2_coal,weo,igcc,2163, -h2_coal_ccs,weo,igcc_ccs,2252, +h2_coal_ccs,weo,igcc,2252, h2_elec,weo,csp,1139, h2_fc_I,weo,igcc,3500, h2_fc_RC,weo,igcc,3500, @@ -55,15 +55,15 @@ hydro_lc,weo,hydropower_large,2187, igcc,weo,igcc,2106, igcc_ccs,weo,igcc_ccs,4819, liq_bio,weo,igcc,4264, -liq_bio_ccs,weo,igcc_ccs,4344, +liq_bio_ccs,weo,igcc,4344, loil_cc,weo,igcc,800, loil_i,weo,ccgt_chp,93, loil_ppl,weo,igcc,600, meth_coal,weo,igcc,2348, -meth_coal_ccs,weo,igcc_ccs,2385, +meth_coal_ccs,weo,igcc,2385, meth_i,weo,bioenergy_medium_chp,93, meth_ng,weo,igcc,1234, -meth_ng_ccs,weo,igcc_ccs,1339, +meth_ng_ccs,weo,igcc,1339, nuc_hc,weo,nuclear,5000, nuc_lc,weo,nuclear,3800, solar_i,weo,solarpv_buildings,737, @@ -73,6 +73,6 @@ solar_pv_RC,weo,solarpv_buildings,, solar_th_ppl,weo,csp,969, stor_ppl,weo,csp,800, syn_liq,weo,igcc,3224, -syn_liq_ccs,weo,igcc_ccs,3268, +syn_liq_ccs,weo,igcc,3268, wind_ppf,weo,wind_offshore,1771, wind_ppl,weo,wind_onshore,1181, \ No newline at end of file diff --git a/message_ix_models/tests/tools/costs/test_decay.py b/message_ix_models/tests/tools/costs/test_decay.py index 1b5b678d2b..1b8c436fc9 100644 --- a/message_ix_models/tests/tools/costs/test_decay.py +++ b/message_ix_models/tests/tools/costs/test_decay.py @@ -34,11 +34,13 @@ def test_get_cost_reduction_data(module: str, t_exp) -> None: @pytest.mark.parametrize("module", ("energy", "materials")) def test_get_technology_reduction_scenarios_data(module: str) -> None: + config = Config() # The function runs without error - result = get_technology_reduction_scenarios_data(Config.base_year, module=module) + result = get_technology_reduction_scenarios_data(config.y0, module=module) - # All first technology years are equal to or greater than the default base year - assert Config.base_year <= result.first_technology_year.min() + # All first technology years are equal to or greater than + # the default first model year + assert config.y0 <= result.first_technology_year.min() # Data for LED and SSP1-5 scenarios are present assert {"SSP1", "SSP2", "SSP3", "SSP4", "SSP5", "LED"} <= set( @@ -70,5 +72,5 @@ def test_project_ref_region_inv_costs_using_reduction_rates( # Excluded technologies are *not* present assert set() == (t_excluded & t) - # The first technology year is equal to or greater than the default base year - assert Config.base_year <= result.first_technology_year.min() + # The first technology year is equal to or greater than the default first model year + assert config.y0 <= result.first_technology_year.min() diff --git a/message_ix_models/tools/costs/config.py b/message_ix_models/tools/costs/config.py index c3cebfa368..24b192b3bc 100644 --- a/message_ix_models/tools/costs/config.py +++ b/message_ix_models/tools/costs/config.py @@ -15,7 +15,10 @@ class Config: """ #: Base year for projected costs. - base_year: int = 2021 + #: This is the first year for which cost reductions/decay are calculated. + #: If the base year is greater than y0 (first model year), + #: then the costs are assumed to be the same from y0 to base_year. + base_year: int = 2025 #: Year of convergence; used when :attr:`.method` is "convergence". This is the year #: by which costs in all regions should converge to the reference region's costs. @@ -27,9 +30,10 @@ class Config: final_year: int = 2100 #: Rate of exponential growth (positive values) or decrease of fixed operating and - #: maintenance costs over time. The default of 0.025 implies exponential growth at a + #: maintenance costs over time. The default of 0 implies no change over time. + #: If the rate is 0.025, for example, that implies exponential growth at a #: rate of 2.5% per year; or :py:`(1 + 0.025) ** N` for a period of length N. - fom_rate: float = 0.025 + fom_rate: float = 0 #: Format of output from :func:`.create_cost_projections`. One of: #: diff --git a/message_ix_models/tools/costs/decay.py b/message_ix_models/tools/costs/decay.py index 5626cc301a..7c120ad9aa 100644 --- a/message_ix_models/tools/costs/decay.py +++ b/message_ix_models/tools/costs/decay.py @@ -115,7 +115,7 @@ def get_cost_reduction_data(module) -> pd.DataFrame: def get_technology_reduction_scenarios_data( - base_year: int, module: str + first_year: int, module: str ) -> pd.DataFrame: """Read in technology first year and cost reduction scenarios. @@ -184,13 +184,13 @@ def get_technology_reduction_scenarios_data( .assign( first_technology_year=lambda x: np.where( x.first_year_original.isnull(), - base_year, + first_year, x.first_year_original, ) ) .assign( first_technology_year=lambda x: np.where( - x.first_year_original > base_year, x.first_year_original, base_year + x.first_year_original > first_year, x.first_year_original, first_year ) ) .drop(columns=["first_year_original"]) @@ -309,9 +309,7 @@ def project_ref_region_inv_costs_using_reduction_rates( df_cost_reduction = get_cost_reduction_data(config.module) # Get scenarios data - df_scenarios = get_technology_reduction_scenarios_data( - config.base_year, config.module - ) + df_scenarios = get_technology_reduction_scenarios_data(config.y0, config.module) # Merge cost reduction data with cost reduction rates data df_cost_reduction = df_cost_reduction.merge( @@ -336,10 +334,9 @@ def project_ref_region_inv_costs_using_reduction_rates( for y in config.seq_years: df_ref = df_ref.assign( ycur=lambda x: np.where( - y <= config.y0, + y <= config.base_year, x.reg_cost_base_year, - (x.reg_cost_base_year - x.b) - * np.exp(x.r * (y - x.first_technology_year)) + (x.reg_cost_base_year - x.b) * np.exp(x.r * (y - config.base_year)) + x.b, ) ).rename(columns={"ycur": y}) diff --git a/message_ix_models/tools/costs/demo.py b/message_ix_models/tools/costs/demo.py index 0f2a2d4720..5ba31bef41 100644 --- a/message_ix_models/tools/costs/demo.py +++ b/message_ix_models/tools/costs/demo.py @@ -13,7 +13,7 @@ # Defaults for all configuration settings: # - base_year=BASE_YEAR, # - convergence_year=2050, -# - fom_rate=0.025, +# - fom_rate=0, # - format="message", # - method="gdp", # - module="energy", diff --git a/message_ix_models/tools/costs/projections.py b/message_ix_models/tools/costs/projections.py index bdfe6e164e..4ef35cf713 100644 --- a/message_ix_models/tools/costs/projections.py +++ b/message_ix_models/tools/costs/projections.py @@ -92,7 +92,7 @@ def create_projections_constant(config: "Config"): df_region_diff.merge(df_ref_reg_decay, on="message_technology") .assign( inv_cost=lambda x: np.where( - x.year <= config.y0, + x.year <= config.base_year, x.reg_cost_base_year, x.inv_cost_ref_region_decay * x.reg_cost_ratio, ), @@ -104,6 +104,7 @@ def create_projections_constant(config: "Config"): "scenario_version", "scenario", "message_technology", + "first_technology_year", "region", "year", "inv_cost", @@ -173,7 +174,7 @@ def create_projections_gdp(config: "Config"): ) .assign( inv_cost=lambda x: np.where( - x.year <= config.y0, + x.year <= config.base_year, x.reg_cost_base_year, x.inv_cost_ref_region_decay * x.reg_cost_ratio_adj, ), @@ -184,6 +185,7 @@ def create_projections_gdp(config: "Config"): "scenario_version", "scenario", "message_technology", + "first_technology_year", "region", "year", "inv_cost", @@ -243,7 +245,7 @@ def create_projections_converge(config: "Config"): df_region_diff.merge(df_ref_reg_cost_reduction, on="message_technology") .assign( inv_cost_tmp=lambda x: np.where( - x.year <= config.y0, + x.year <= config.base_year, x.reg_cost_base_year, np.where( x.year < config.convergence_year, @@ -273,7 +275,9 @@ def _predict(df: pd.DataFrame) -> pd.Series: # Apply polynomial regression to costs at base year and convergence year # (interpolating) df_pre_converge_costs = ( - df_tmp_costs.query("year == @config.y0 or year == @config.convergence_year") + df_tmp_costs.query( + "year == @config.base_year or year == @config.convergence_year" + ) .groupby(cols[:3], group_keys=True) .apply(_predict) .reset_index() @@ -287,7 +291,7 @@ def _predict(df: pd.DataFrame) -> pd.Series: ) .assign( inv_cost_converge=lambda x: np.where( - x.year <= config.y0, + x.year <= config.base_year, x.reg_cost_base_year, np.where( x.region == config.ref_region, @@ -315,6 +319,7 @@ def _predict(df: pd.DataFrame) -> pd.Series: "scenario_version", "scenario", "message_technology", + "first_technology_year", "region", "year", "inv_cost", @@ -362,6 +367,7 @@ def create_message_outputs( df_projections.scenario_version.unique(), df_projections.scenario.unique(), df_projections.message_technology.unique(), + df_projections.first_technology_year.unique(), df_projections.region.unique(), config.seq_years, ), @@ -369,6 +375,7 @@ def create_message_outputs( "scenario_version", "scenario", "message_technology", + "first_technology_year", "region", "year", ], @@ -402,6 +409,7 @@ def create_message_outputs( "scenario_version", "scenario", "message_technology", + "first_technology_year", "region", "year", ], @@ -429,6 +437,7 @@ def create_message_outputs( scenario=str, node_loc=str, technology=str, + first_technology_year=str, unit=str, year_vtg=int, value=float, @@ -450,6 +459,7 @@ def create_message_outputs( "scenario", "node_loc", "technology", + "first_technology_year", "year_vtg", "value", "unit", @@ -458,8 +468,21 @@ def create_message_outputs( ) .astype(dtypes) .query("year_vtg in @config.Y") + .assign(first_technology_year=lambda x: x.first_technology_year.astype(float)) + .assign(first_technology_year=lambda x: x.first_technology_year.astype(int)) + .query("year_vtg >= first_technology_year") .reset_index(drop=True) - .drop_duplicates() + .drop_duplicates()[ + [ + "scenario_version", + "scenario", + "node_loc", + "technology", + "year_vtg", + "value", + "unit", + ] + ] ) dtypes.update(year_act=int) @@ -478,9 +501,19 @@ def create_message_outputs( np.where( x.year_act <= y_base, x.fix_cost, - x.fix_cost * (1 + (config.fom_rate)) ** (x.year_act - y_base), + np.where( + config.fom_rate == 0, + x.fix_cost, + x.fix_cost + * (1 + float(config.fom_rate)) ** (x.year_act - y_base), + ), + ), + np.where( + config.fom_rate == 0, + x.fix_cost, + x.fix_cost + * (1 + float(config.fom_rate)) ** (x.year_act - x.year_vtg), ), - x.fix_cost * (1 + (config.fom_rate)) ** (x.year_act - x.year_vtg), ) ) .assign(unit="USD/kWa") @@ -497,6 +530,7 @@ def create_message_outputs( "scenario", "node_loc", "technology", + "first_technology_year", "year_vtg", "year_act", "value", @@ -506,8 +540,22 @@ def create_message_outputs( ) .astype(dtypes) .query("year_act in @config.Y and year_vtg in @config.Y") + .assign(first_technology_year=lambda x: x.first_technology_year.astype(float)) + .assign(first_technology_year=lambda x: x.first_technology_year.astype(int)) + .query("year_vtg >= first_technology_year") .reset_index(drop=True) - ).drop_duplicates() + ).drop_duplicates()[ + [ + "scenario_version", + "scenario", + "node_loc", + "technology", + "year_vtg", + "year_act", + "value", + "unit", + ] + ] return inv, fom diff --git a/message_ix_models/tools/costs/regional_differentiation.py b/message_ix_models/tools/costs/regional_differentiation.py index f4ed0ab66c..376d88ee91 100644 --- a/message_ix_models/tools/costs/regional_differentiation.py +++ b/message_ix_models/tools/costs/regional_differentiation.py @@ -419,9 +419,8 @@ def get_weo_regional_differentiation(config: "Config") -> pd.DataFrame: # Grab WEO data and keep only investment costs df_weo = get_weo_data() - # Get list of years in WEO data and select year closest to base year - l_years = df_weo.year.unique() - sel_year = min(l_years, key=lambda x: abs(int(x) - config.base_year)) + # Even if config.base_year is greater than 2021, use 2021 WEO data + sel_year = str(2021) log.info("…using year " + str(sel_year) + " data from WEO") # - Retrieve a map from MESSAGEix node IDs to WEO region names.