diff --git a/config/config.default.yaml b/config/config.default.yaml index d7704a276..9857000e3 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -506,6 +506,9 @@ sector: OCGT: gas biomass_to_liquid: false biosng: false + endogenous_steel: false + relocation_steel: false + flexibility_steel: false limit_max_growth: enable: false # allowing 30% larger than max historic growth @@ -576,7 +579,7 @@ industry: # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#costs costs: year: 2030 - version: v0.6.0 + version: v0.6.2 rooftop_share: 0.14 # based on the potentials, assuming (0.1 kW/m2 and 10 m2/person) social_discountrate: 0.02 fill_values: @@ -1000,4 +1003,5 @@ plotting: DC: "#8a1caf" DC-DC: "#8a1caf" DC link: "#8a1caf" + DRI + Electric arc: '#b1bbc9' load: "#dd2e23" diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index 280c19064..b51fcc112 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -122,3 +122,6 @@ biogas_upgrading_cc,--,"{true, false}",Add option to capture CO2 from biomass up conventional_generation,,,Add a more detailed description of conventional carriers. Any power generation requires the consumption of fuel from nodes representing that fuel. biomass_to_liquid,--,"{true, false}",Add option for transforming solid biomass into liquid fuel with the same properties as oil biosng,--,"{true, false}",Add option for transforming solid biomass into synthesis gas with the same properties as natural gas +endogenous_steel,--,"{true, false}",Add option for modelling steel as a separate carrier with endogenous optimisation of DRI+EAF capacities and operation. +relocation_steel,--,"{true, false}",Add option for steel production to relocate to regions other than the current locations of the steel production. +flexibility_steel,--,"{true, false}",Add option for steel production to operate flexibly (e.g. in batches). diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9f1f43d31..08b02016a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -72,6 +72,20 @@ Upcoming Release reconnected to the main Ukrainian grid with the configuration option `reconnect_crimea`. +* Added option to endogenously optimise the capacity and operation of green + steel production (``sector: endogenous_steel:``), whereby a bus for steel and + a combined link for DRI+EAF are introduced. The link includes the capital cost + for DRI and EAF as well as the cost for the iron ore. The option is disabled + by default. + + By default, plants run with a constant production rate. This can be changed by + setting ``sector: flexibility_steel: true``. + + By default, steel plants cannot relocate. This can be changed by setting + ``sector: relocation_steel: true``. Relocation costs are not included. + +* Switch to new ``technology-data`` version 0.6.2. + * Validate downloads from Zenodo using MD5 checksums. This identifies corrupted or incomplete downloads. diff --git a/rules/build_sector.smk b/rules/build_sector.smk index dd49fc6f0..6f6a41fa5 100644 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -532,8 +532,6 @@ rule build_industrial_energy_demand_per_node: industry_sector_ratios=RESOURCES + "industry_sector_ratios.csv", industrial_production_per_node=RESOURCES + "industrial_production_elec_s{simpl}_{clusters}_{planning_horizons}.csv", - industrial_energy_demand_per_node_today=RESOURCES - + "industrial_energy_demand_today_elec_s{simpl}_{clusters}.csv", output: industrial_energy_demand_per_node=RESOURCES + "industrial_energy_demand_elec_s{simpl}_{clusters}_{planning_horizons}.csv", @@ -753,8 +751,13 @@ rule prepare_sector_network: busmap=RESOURCES + "busmap_elec_s{simpl}_{clusters}.csv", clustered_pop_layout=RESOURCES + "pop_layout_elec_s{simpl}_{clusters}.csv", simplified_pop_layout=RESOURCES + "pop_layout_elec_s{simpl}.csv", + industrial_production=RESOURCES + + "industrial_production_elec_s{simpl}_{clusters}_{planning_horizons}.csv", industrial_demand=RESOURCES + "industrial_energy_demand_elec_s{simpl}_{clusters}_{planning_horizons}.csv", + industrial_demand_today=RESOURCES + + "industrial_energy_demand_today_elec_s{simpl}_{clusters}.csv", + industry_sector_ratios=RESOURCES + "industry_sector_ratios.csv", heat_demand_urban=RESOURCES + "heat_demand_urban_elec_s{simpl}_{clusters}.nc", heat_demand_rural=RESOURCES + "heat_demand_rural_elec_s{simpl}_{clusters}.nc", heat_demand_total=RESOURCES + "heat_demand_total_elec_s{simpl}_{clusters}.nc", diff --git a/scripts/build_industrial_energy_demand_per_node.py b/scripts/build_industrial_energy_demand_per_node.py index 55c10c5d6..039ffb003 100644 --- a/scripts/build_industrial_energy_demand_per_node.py +++ b/scripts/build_industrial_energy_demand_per_node.py @@ -27,12 +27,9 @@ fn = snakemake.input.industrial_production_per_node nodal_production = pd.read_csv(fn, index_col=0) - # energy demand today to get current electricity - fn = snakemake.input.industrial_energy_demand_per_node_today - nodal_today = pd.read_csv(fn, index_col=0) - - # final energy consumption per node and industry (TWh/a) - nodal_df = nodal_production.dot(industry_sector_ratios.T) + # final energy consumption per node, sector and carrier + nodal_dict = {k: s * industry_sector_ratios for k, s in nodal_production.iterrows()} + nodal_df = pd.concat(nodal_dict, axis=1).T # convert GWh to TWh and ktCO2 to MtCO2 nodal_df *= 0.001 @@ -44,8 +41,7 @@ } nodal_df.rename(columns=rename_sectors, inplace=True) - nodal_df["current electricity"] = nodal_today["electricity"] - + nodal_df.index.set_names(["node", "sector"], inplace=True) nodal_df.index.name = "TWh/a (MtCO2/a)" fn = snakemake.output.industrial_energy_demand_per_node diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index e53f215e4..05652ea83 100644 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2429,8 +2429,105 @@ def add_industry(n, costs): # 1e6 to convert TWh to MWh industrial_demand = ( - pd.read_csv(snakemake.input.industrial_demand, index_col=0) * 1e6 - ) * nyears + pd.read_csv(snakemake.input.industrial_demand, index_col=[0, 1]) * 1e6 * nyears + ) + industrial_demand_today = ( + pd.read_csv(snakemake.input.industrial_demand_today, index_col=0) * 1e6 * nyears + ) + + industrial_production = ( + pd.read_csv(snakemake.input.industrial_production, index_col=0) + * 1e3 + * nyears # kt/a -> t/a + ) + + endogenous_sectors = ["DRI + Electric arc"] if options["endogenous_steel"] else [] + sectors_b = ~industrial_demand.index.get_level_values("sector").isin( + endogenous_sectors + ) + + if options["endogenous_steel"]: + logger.info("Adding endogenous primary steel demand in tonnes.") + + sector = "DRI + Electric arc" + + no_relocation = not options.get("relocation_steel", False) + no_flexibility = not options.get("flexibility_steel", False) + + s = " not" if no_relocation else " " + logger.info(f"Steel industry relocation{s} activated.") + + s = " not" if no_flexibility else " " + logger.info(f"Steel industry flexibility{s} activated.") + + n.add( + "Bus", + "EU steel", + location="EU", + carrier="steel", + unit="t", + ) + + n.add( + "Load", + "EU steel", + bus="EU steel", + carrier="steel", + p_set=industrial_production[sector].sum() / nhours, + ) + + if not no_flexibility: + n.add( + "Store", + "EU steel Store", + bus="EU steel", + e_nom_extendable=True, + e_cyclic=True, + carrier="steel", + ) + + electricity_input = ( + costs.at["direct iron reduction furnace", "electricity-input"] + * costs.at["electric arc furnace", "hbi-input"] + + costs.at["electric arc furnace", "electricity-input"] + ) + + hydrogen_input = ( + costs.at["direct iron reduction furnace", "hydrogen-input"] + * costs.at["electric arc furnace", "hbi-input"] + ) + + # so that for each region supply matches consumption + p_nom = industrial_production[sector] * electricity_input / nhours + + marginal_cost = ( + costs.at["iron ore DRI-ready", "commodity"] + * costs.at["direct iron reduction furnace", "ore-input"] + * costs.at["electric arc furnace", "hbi-input"] + / electricity_input + ) + + capital_cost = ( + costs.at["direct iron reduction furnace", "fixed"] + + costs.at["electric arc furnace", "fixed"] + ) / electricity_input + + n.madd( + "Link", + nodes, + suffix=f" {sector}", + carrier=sector, + capital_cost=capital_cost, + marginal_cost=marginal_cost, + p_nom_max=p_nom if no_relocation else np.inf, + p_nom_extendable=True, + p_min_pu=1 if no_flexibility else 0, + bus0=nodes, + bus1="EU steel", + bus2=nodes + " H2", + efficiency=1 / electricity_input, + efficiency2=-hydrogen_input / electricity_input, + ) n.madd( "Bus", @@ -2442,13 +2539,16 @@ def add_industry(n, costs): if options.get("biomass_spatial", options["biomass_transport"]): p_set = ( - industrial_demand.loc[spatial.biomass.locations, "solid biomass"].rename( - index=lambda x: x + " solid biomass for industry" - ) + industrial_demand.loc[ + (spatial.biomass.locations, sectors_b), "solid biomass" + ] + .groupby(level="nodes") + .sum() + .rename(index=lambda x: x + " solid biomass for industry") / nhours ) else: - p_set = industrial_demand["solid biomass"].sum() / nhours + p_set = industrial_demand.loc[sectors_b, "solid biomass"].sum() / nhours n.madd( "Load", @@ -2495,7 +2595,10 @@ def add_industry(n, costs): unit="MWh_LHV", ) - gas_demand = industrial_demand.loc[nodes, "methane"] / nhours + gas_demand = ( + industrial_demand.loc[(nodes, sectors_b), "methane"].groupby(level="node").sum() + / nhours + ) if options["gas_network"]: spatial_gas_demand = gas_demand.rename(index=lambda x: x + " gas for industry") @@ -2547,7 +2650,10 @@ def add_industry(n, costs): suffix=" H2 for industry", bus=nodes + " H2", carrier="H2 for industry", - p_set=industrial_demand.loc[nodes, "hydrogen"] / nhours, + p_set=industrial_demand.loc[(nodes, sectors_b), "hydrogen"] + .groupby(level="node") + .sum() + / nhours, ) shipping_hydrogen_share = get(options["shipping_hydrogen_share"], investment_year) @@ -2778,7 +2884,11 @@ def add_industry(n, costs): ) demand_factor = options.get("HVC_demand_factor", 1) - p_set = demand_factor * industrial_demand.loc[nodes, "naphtha"].sum() / nhours + p_set = ( + demand_factor + * industrial_demand.loc[(nodes, sectors_b), "naphtha"].sum() + / nhours + ) if demand_factor != 1: logger.warning(f"Changing HVC demand by {demand_factor*100-100:+.2f}%.") @@ -2815,7 +2925,10 @@ def add_industry(n, costs): co2_release = ["naphtha for industry", "kerosene for aviation"] co2 = ( n.loads.loc[co2_release, "p_set"].sum() * costs.at["oil", "CO2 intensity"] - - industrial_demand.loc[nodes, "process emission from feedstock"].sum() / nhours + - industrial_demand.loc[ + (nodes, sectors_b), "process emission from feedstock" + ].sum() + / nhours ) n.add( @@ -2838,7 +2951,10 @@ def add_industry(n, costs): for node in nodes ], carrier="low-temperature heat for industry", - p_set=industrial_demand.loc[nodes, "low-temperature heat"] / nhours, + p_set=industrial_demand.loc[(nodes, sectors_b), "low-temperature heat"] + .groupby(level="node") + .sum() + / nhours, ) # remove today's industrial electricity demand by scaling down total electricity demand @@ -2852,7 +2968,7 @@ def add_industry(n, costs): continue factor = ( 1 - - industrial_demand.loc[loads_i, "current electricity"].sum() + - industrial_demand_today.loc[loads_i, "electricity"].sum() / n.loads_t.p_set[loads_i].sum().sum() ) n.loads_t.p_set[loads_i] *= factor @@ -2863,7 +2979,10 @@ def add_industry(n, costs): suffix=" industry electricity", bus=nodes, carrier="industry electricity", - p_set=industrial_demand.loc[nodes, "electricity"] / nhours, + p_set=industrial_demand.loc[(nodes, sectors_b), "electricity"] + .groupby(level="node") + .sum() + / nhours, ) n.madd( @@ -2877,13 +2996,17 @@ def add_industry(n, costs): sel = ["process emission", "process emission from feedstock"] if options["co2_spatial"] or options["co2network"]: p_set = ( - -industrial_demand.loc[nodes, sel] + -industrial_demand.loc[(nodes, sectors_b), sel] + .groupby(level="node") + .sum() .sum(axis=1) .rename(index=lambda x: x + " process emissions") / nhours ) else: - p_set = -industrial_demand.loc[nodes, sel].sum(axis=1).sum() / nhours + p_set = ( + -industrial_demand.loc[(nodes, sectors_b), sel].sum(axis=1).sum() / nhours + ) # this should be process emissions fossil+feedstock # then need load on atmosphere for feedstock emissions that are currently going to atmosphere via Link Fischer-Tropsch demand @@ -2924,13 +3047,14 @@ def add_industry(n, costs): if options.get("ammonia"): if options["ammonia"] == "regional": p_set = ( - industrial_demand.loc[spatial.ammonia.locations, "ammonia"].rename( - index=lambda x: x + " NH3" - ) + industrial_demand.loc[(spatial.ammonia.locations, sectors_b), "ammonia"] + .groupby(level="node") + .sum() + .rename(index=lambda x: x + " NH3") / nhours ) else: - p_set = industrial_demand["ammonia"].sum() / nhours + p_set = industrial_demand.loc[sectors_b, "ammonia"].sum() / nhours n.madd( "Load", @@ -3482,6 +3606,11 @@ def set_temporal_aggregation(n, opts, solver_name): options["use_fuel_cell_waste_heat"] = False options["use_electrolysis_waste_heat"] = False + if "nosteelrelocation" in opts: + logger.info("Disabling steel industry relocation and flexibility.") + options["relocation_steel"] = False + options["flexibility_steel"] = False + if "T" in opts: add_land_transport(n, costs)