diff --git a/doc/release_notes.rst b/doc/release_notes.rst index e75d3344..7340fe31 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -10,6 +10,8 @@ Release Notes .. Upcoming Release +* Change the heating demand from final energy which includes losses in legacy equipment to thermal energy service based on JRC-IDEES. Efficiencies of existing heating capacities are lowered according to the conversion of final energy to thermal energy service. For overnight scenarios or future planning horizon this change leads to a reduction in heat supply. + * Updated district heating supply temperatures based on `Euroheat's DHC Market Outlook 2024`__ and `AGFW-Hauptbericht 2022 `__. `min_forward_temperature` and `return_temperature` (not given by Euroheat) are extrapolated based on German values. * Made the overdimensioning factor for heating systems specific for central/decentral heating, defaults to no overdimensionining for central heating and no changes to decentral heating compared to previous version. diff --git a/rules/build_sector.smk b/rules/build_sector.smk index ccb56aaa..60db6c14 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -366,6 +366,7 @@ rule build_energy_totals: co2_name=resources("co2_totals.csv"), transport_name=resources("transport_data.csv"), district_heat_share=resources("district_heat_share.csv"), + heating_efficiencies=resources("heating_efficiencies.csv"), threads: 16 resources: mem_mb=10000, @@ -1015,6 +1016,7 @@ rule prepare_sector_network: RDIR=RDIR, heat_pump_sources=config_provider("sector", "heat_pump_sources"), heat_systems=config_provider("sector", "heat_systems"), + energy_totals_year=config_provider("energy", "energy_totals_year"), input: unpack(input_profile_offwind), **rules.cluster_gas_network.output, @@ -1089,6 +1091,7 @@ rule prepare_sector_network: district_heat_share=resources( "district_heat_share_elec_s{simpl}_{clusters}_{planning_horizons}.csv" ), + heating_efficiencies=resources("heating_efficiencies.csv"), temp_soil_total=resources("temp_soil_total_elec_s{simpl}_{clusters}.nc"), temp_air_total=resources("temp_air_total_elec_s{simpl}_{clusters}.nc"), cop_profiles=resources("cop_profiles_elec_s{simpl}_{clusters}.nc"), diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 8d7fa284..6340787a 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -10,6 +10,7 @@ rule add_existing_baseyear: existing_capacities=config_provider("existing_capacities"), costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), + energy_totals_year=config_provider("energy", "energy_totals_year"), input: network=RESULTS + "prenetworks/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", @@ -26,6 +27,7 @@ rule add_existing_baseyear: existing_heating_distribution=resources( "existing_heating_distribution_elec_s{simpl}_{clusters}_{planning_horizons}.csv" ), + heating_efficiencies=resources("heating_efficiencies.csv"), output: RESULTS + "prenetworks-brownfield/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index a06c6dfa..f1ec9966 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -8,6 +8,7 @@ rule add_existing_baseyear: existing_capacities=config_provider("existing_capacities"), costs=config_provider("costs"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), + energy_totals_year=config_provider("energy", "energy_totals_year"), input: network=RESULTS + "prenetworks/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", @@ -25,6 +26,7 @@ rule add_existing_baseyear: "existing_heating_distribution_elec_s{simpl}_{clusters}_{planning_horizons}.csv" ), existing_heating="data/existing_infrastructure/existing_heating_raw.csv", + heating_efficiencies=resources("heating_efficiencies.csv"), output: RESULTS + "prenetworks-brownfield/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index 212ae8af..84d20b72 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -418,6 +418,50 @@ def add_power_capacities_installed_before_baseyear(n, grouping_years, costs, bas ] +def get_efficiency(heat_system, carrier, nodes, heating_efficiencies, costs): + """ + Computes the heating system efficiency based on the sector and carrier + type. + + Parameters: + ----------- + heat_system : object + carrier : str + The type of fuel or energy carrier (e.g., 'gas', 'oil'). + nodes : pandas.Series + A pandas Series containing node information used to match the heating efficiency data. + heating_efficiencies : dict + A dictionary containing efficiency values for different carriers and sectors. + costs : pandas.DataFrame + A DataFrame containing boiler cost and efficiency data for different heating systems. + + Returns: + -------- + efficiency : pandas.Series or float + A pandas Series mapping the efficiencies based on nodes for residential and services sectors, or a single + efficiency value for other heating systems (e.g., urban central). + + Notes: + ------ + - For residential and services sectors, efficiency is mapped based on the nodes. + - For other sectors, the default boiler efficiency is retrieved from the `costs` database. + """ + + if heat_system.value == "urban central": + boiler_costs_name = getattr(heat_system, f"{carrier}_boiler_costs_name") + efficiency = costs.at[boiler_costs_name, "efficiency"] + elif heat_system.sector.value == "residential": + key = f"{carrier} residential space efficiency" + efficiency = nodes.str[:2].map(heating_efficiencies[key]) + elif heat_system.sector.value == "services": + key = f"{carrier} services space efficiency" + efficiency = nodes.str[:2].map(heating_efficiencies[key]) + else: + logger.warning(f"{heat_system} not defined.") + + return efficiency + + def add_heating_capacities_installed_before_baseyear( n: pypsa.Network, baseyear: int, @@ -546,6 +590,10 @@ def add_heating_capacities_installed_before_baseyear( lifetime=costs.at[heat_system.resistive_heater_costs_name, "lifetime"], ) + efficiency = get_efficiency( + heat_system, "gas", nodes, heating_efficiencies, costs + ) + n.madd( "Link", nodes, @@ -554,7 +602,7 @@ def add_heating_capacities_installed_before_baseyear( bus1=nodes + " " + heat_system.value + " heat", bus2="co2 atmosphere", carrier=heat_system.value + " gas boiler", - efficiency=costs.at[heat_system.gas_boiler_costs_name, "efficiency"], + efficiency=efficiency, efficiency2=costs.at["gas", "CO2 intensity"], capital_cost=( costs.at[heat_system.gas_boiler_costs_name, "efficiency"] @@ -569,6 +617,10 @@ def add_heating_capacities_installed_before_baseyear( lifetime=costs.at[heat_system.gas_boiler_costs_name, "lifetime"], ) + efficiency = get_efficiency( + heat_system, "oil", nodes, heating_efficiencies, costs + ) + n.madd( "Link", nodes, @@ -577,7 +629,7 @@ def add_heating_capacities_installed_before_baseyear( bus1=nodes + " " + heat_system.value + " heat", bus2="co2 atmosphere", carrier=heat_system.value + " oil boiler", - efficiency=costs.at[heat_system.oil_boiler_costs_name, "efficiency"], + efficiency=efficiency, efficiency2=costs.at["oil", "CO2 intensity"], capital_cost=costs.at[heat_system.oil_boiler_costs_name, "efficiency"] * costs.at[heat_system.oil_boiler_costs_name, "fixed"], @@ -621,12 +673,12 @@ if __name__ == "__main__": snakemake = mock_snakemake( "add_existing_baseyear", - configfiles="config/config.yaml", + configfiles="config/test/config.myopic.yaml", simpl="", - clusters="20", + clusters="5", ll="v1.5", opts="", - sector_opts="none", + sector_opts="", planning_horizons=2030, ) @@ -660,6 +712,11 @@ if __name__ == "__main__": if options["heating"]: + # one could use baseyear here instead (but dangerous if no data) + fn = snakemake.input.heating_efficiencies + year = int(snakemake.params["energy_totals_year"]) + heating_efficiencies = pd.read_csv(fn, index_col=[1, 0]).loc[year] + add_heating_capacities_installed_before_baseyear( n=n, baseyear=baseyear, diff --git a/scripts/build_energy_totals.py b/scripts/build_energy_totals.py index 2802d8b3..6afa6b33 100644 --- a/scripts/build_energy_totals.py +++ b/scripts/build_energy_totals.py @@ -367,6 +367,24 @@ def idees_per_country(ct: str, base_dir: str) -> pd.DataFrame: assert df.index[43] == "Thermal uses" ct_totals["thermal uses residential"] = df.iloc[43] + df = pd.read_excel(fn_residential, "RES_hh_eff", index_col=0) + + ct_totals["total residential space efficiency"] = df.loc["Space heating"] + + assert df.index[5] == "Diesel oil" + ct_totals["oil residential space efficiency"] = df.iloc[5] + + assert df.index[6] == "Natural gas" + ct_totals["gas residential space efficiency"] = df.iloc[6] + + ct_totals["total residential water efficiency"] = df.loc["Water heating"] + + assert df.index[18] == "Diesel oil" + ct_totals["oil residential water efficiency"] = df.iloc[18] + + assert df.index[19] == "Natural gas" + ct_totals["gas residential water efficiency"] = df.iloc[19] + # services df = pd.read_excel(fn_tertiary, "SER_hh_fec", index_col=0) @@ -400,6 +418,24 @@ def idees_per_country(ct: str, base_dir: str) -> pd.DataFrame: assert df.index[46] == "Thermal uses" ct_totals["thermal uses services"] = df.iloc[46] + df = pd.read_excel(fn_tertiary, "SER_hh_eff", index_col=0) + + ct_totals["total services space efficiency"] = df.loc["Space heating"] + + assert df.index[5] == "Diesel oil" + ct_totals["oil services space efficiency"] = df.iloc[5] + + assert df.index[7] == "Conventional gas heaters" + ct_totals["gas services space efficiency"] = df.iloc[7] + + ct_totals["total services water efficiency"] = df.loc["Hot water"] + + assert df.index[20] == "Diesel oil" + ct_totals["oil services water efficiency"] = df.iloc[20] + + assert df.index[21] == "Natural gas" + ct_totals["gas services water efficiency"] = df.iloc[21] + # agriculture, forestry and fishing start = "Detailed split of energy consumption (ktoe)" @@ -576,7 +612,8 @@ def build_idees(countries: List[str]) -> pd.DataFrame: # efficiency kgoe/100km -> ktoe/100km so that after conversion TWh/100km totals.loc[:, "passenger car efficiency"] /= 1e6 # convert ktoe to TWh - exclude = totals.columns.str.fullmatch("passenger cars") + patterns = ["passenger cars", ".*space efficiency", ".*water efficiency"] + exclude = totals.columns.str.fullmatch("|".join(patterns)) totals = totals.copy() totals.loc[:, ~exclude] *= 11.63 / 1e3 @@ -654,11 +691,14 @@ def build_energy_totals( eurostat_countries = eurostat.index.unique(0) eurostat_years = eurostat.index.unique(1) - to_drop = ["passenger cars", "passenger car efficiency"] new_index = pd.MultiIndex.from_product( [countries, eurostat_years], names=["country", "year"] ) + efficiency_keywords = ["space efficiency", "water efficiency"] + to_drop = idees.columns[idees.columns.str.contains("|".join(efficiency_keywords))] + to_drop = to_drop.append(pd.Index(["passenger cars", "passenger car efficiency"])) + df = idees.reindex(new_index).drop(to_drop, axis=1) in_eurostat = df.index.levels[0].intersection(eurostat_countries) @@ -1501,6 +1541,59 @@ def build_transformation_output_coke(eurostat, fn): df.to_csv(fn) +def build_heating_efficiencies( + countries: List[str], idees: pd.DataFrame +) -> pd.DataFrame: + """ + Build heating efficiencies for a set of countries based on IDEES data. + + Parameters + ---------- + countries : List[str] + List of country codes. + idees : pd.DataFrame + DataFrame with IDEES data. + + Returns + ------- + pd.DataFrame + DataFrame with heating efficiencies. + + + Notes + ----- + - It fills missing data with average data. + """ + + years = np.arange(2000, 2022) + + cols = idees.columns[ + idees.columns.str.contains("space efficiency") + ^ idees.columns.str.contains("water efficiency") + ] + + heating_efficiencies = pd.DataFrame(idees[cols]) + + new_index = pd.MultiIndex.from_product( + [countries, heating_efficiencies.index.unique(1)], + names=["country", "year"], + ) + + heating_efficiencies = heating_efficiencies.reindex(index=new_index) + + for col in cols: + unstacked = heating_efficiencies[col].unstack() + + fillvalue = unstacked.mean() + + for ct in unstacked.index: + mask = unstacked.loc[ct].isna() + unstacked.loc[ct, mask] = fillvalue[mask] + heating_efficiencies[col] = unstacked.stack() + + return heating_efficiencies + + # %% if __name__ == "__main__": if "snakemake" not in globals(): @@ -1556,3 +1649,6 @@ if __name__ == "__main__": transport = build_transport_data(countries, population, idees) transport.to_csv(snakemake.output.transport_name) + + heating_efficiencies = build_heating_efficiencies(countries, idees) + heating_efficiencies.to_csv(snakemake.output.heating_efficiencies) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6ef86cc4..4ddcac32 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1839,9 +1839,14 @@ def build_heat_demand(n): for sector, use in product(sectors, uses): name = f"{sector} {use}" + # efficiency for final energy to thermal energy service + eff = pop_weighted_energy_totals.index.str[:2].map( + heating_efficiencies[f"total {sector} {use} efficiency"] + ) + heat_demand[name] = ( heat_demand_shape[name] / heat_demand_shape[name].sum() - ).multiply(pop_weighted_energy_totals[f"total {sector} {use}"]) * 1e6 + ).multiply(pop_weighted_energy_totals[f"total {sector} {use}"] * eff) * 1e6 electric_heat_supply[name] = ( heat_demand_shape[name] / heat_demand_shape[name].sum() ).multiply(pop_weighted_energy_totals[f"electricity {sector} {use}"]) * 1e6 @@ -4373,6 +4378,10 @@ if __name__ == "__main__": ) pop_weighted_energy_totals.update(pop_weighted_heat_totals) + fn = snakemake.input.heating_efficiencies + year = int(snakemake.params["energy_totals_year"]) + heating_efficiencies = pd.read_csv(fn, index_col=[1, 0]).loc[year] + patch_electricity_network(n) spatial = define_spatial(pop_layout.index, options)