diff --git a/config/config.default.yaml b/config/config.default.yaml index a475c6fdf..5c4ebef31 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -487,6 +487,9 @@ sector: rural: - air - ground + residential_heat_dsm: false + residential_heat_restriction_value: 0.27 + residential_heat_restriction_time: [10, 22] # 9am and 9pm cluster_heat_buses: true heat_demand_cutout: default bev_dsm_restriction_value: 0.75 diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index eeee192eb..02799ed90 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -27,10 +27,13 @@ district_heating,--,,`prepare_sector_network.py `_. Set to 0 for full restriction on HP DSM, default value is 0.27, +residential_heat_restriction_time,--,list of int, Time at which SOC of HPs has to be residential_heat_restriction_value. Defaults to [10, 22] corresponding to 9am and 9pm, cluster_heat_buses,--,"{true, false}",Cluster residential and service heat buses in `prepare_sector_network.py `_ to one to save memory. ,,, -bev_dsm_restriction _value,--,float,Adds a lower state of charge (SOC) limit for battery electric vehicles (BEV) to manage its own energy demand (DSM). Located in `build_transport_demand.py `_. Set to 0 for no restriction on BEV DSM -bev_dsm_restriction _time,--,float,Time at which SOC of BEV has to be dsm_restriction_value +bev_dsm_restriction_value,--,float,Adds a lower state of charge (SOC) limit for battery electric vehicles (BEV) to manage its own energy demand (DSM). Located in `build_transport_demand.py `_. Set to 0 for no restriction on BEV DSM, +bev_dsm_restriction_time,--,float,Time at which SOC of BEV has to be bev_dsm_restriction_value, transport_heating _deadband_upper,°C,float,"The maximum temperature in the vehicle. At higher temperatures, the energy required for cooling in the vehicle increases." transport_heating _deadband_lower,°C,float,"The minimum temperature in the vehicle. At lower temperatures, the energy required for heating in the vehicle increases." ,,, diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9e615a917..08f1a5098 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -83,6 +83,8 @@ Upcoming Release * Bugfix: Bug when multiple DC links are connected to the same DC bus and the DC bus is connected to an AC bus via converter. In this case, the DC links were wrongly simplified, completely dropping the shared DC bus. Bug fixed by adding preceding converter removal. Other functionalities are not impacted. +* Add demand-side-response (DSR) for the heating sector. + PyPSA-Eur 0.13.0 (13th September 2024) ====================================== diff --git a/rules/build_sector.smk b/rules/build_sector.smk index c56675c17..db58b540f 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -170,11 +170,15 @@ rule build_hourly_heat_demand: params: snapshots=config_provider("snapshots"), drop_leap_day=config_provider("enable", "drop_leap_day"), + sector=config_provider("sector"), input: heat_profile="data/heat_load_profile_BDEW.csv", heat_demand=resources("daily_heat_demand_total_base_s_{clusters}.nc"), output: heat_demand=resources("hourly_heat_demand_total_base_s_{clusters}.nc"), + heat_dsm_profile=resources( + "residential_heat_dsm_profile_total_base_s_{clusters}.csv" + ), resources: mem_mb=2000, threads: 8 @@ -1069,6 +1073,9 @@ rule prepare_sector_network: transport_data=resources("transport_data_s_{clusters}.csv"), avail_profile=resources("avail_profile_s_{clusters}.csv"), dsm_profile=resources("dsm_profile_s_{clusters}.csv"), + heat_dsm_profile=resources( + "residential_heat_dsm_profile_total_base_s_{clusters}.csv" + ), co2_totals_name=resources("co2_totals.csv"), co2="data/bundle/eea/UNFCCC_v23.csv", biomass_potentials=resources( diff --git a/scripts/build_hourly_heat_demand.py b/scripts/build_hourly_heat_demand.py index 9bb1f77ff..9eb9dd166 100644 --- a/scripts/build_hourly_heat_demand.py +++ b/scripts/build_hourly_heat_demand.py @@ -32,10 +32,27 @@ from itertools import product +import numpy as np import pandas as pd import xarray as xr from _helpers import generate_periodic_profiles, get_snapshots, set_scenario_config + +def heat_dsm_profile(nodes, options): + + weekly_profile = np.ones((24 * 7)) + for i in options["residential_heat_restriction_time"]: + weekly_profile[(np.arange(0, 7, 1) * 24 + int(i))] = 0 + + dsm_profile = generate_periodic_profiles( + dt_index=pd.date_range(freq="h", **snakemake.params.snapshots, tz="UTC"), + nodes=nodes, + weekly_profile=weekly_profile, + ) + + return dsm_profile + + if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake @@ -51,6 +68,8 @@ snakemake.params.snapshots, snakemake.params.drop_leap_day ) + options = snakemake.params.sector + daily_space_heat_demand = ( xr.open_dataarray(snakemake.input.heat_demand) .to_pandas() @@ -63,6 +82,7 @@ uses = ["water", "space"] heat_demand = {} + dsm_profile = {} for sector, use in product(sectors, uses): weekday = list(intraday_profiles[f"{sector} {use} weekday"]) weekend = list(intraday_profiles[f"{sector} {use} weekend"]) @@ -77,13 +97,19 @@ heat_demand[f"{sector} {use}"] = ( daily_space_heat_demand * intraday_year_profile ) + if sector == "residential": + dsm_profile[f"{sector} {use}"] = heat_dsm_profile( + daily_space_heat_demand.columns, options + ) else: heat_demand[f"{sector} {use}"] = intraday_year_profile heat_demand = pd.concat(heat_demand, axis=1, names=["sector use", "node"]) + dsm_profile = pd.concat(dsm_profile, axis=1, names=["sector use", "node"]) heat_demand.index.name = "snapshots" ds = heat_demand.stack(future_stack=True).to_xarray() ds.to_netcdf(snakemake.output.heat_demand) + dsm_profile.to_csv(snakemake.output.heat_dsm_profile) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 105018f92..cbeb3a33a 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2143,6 +2143,50 @@ def add_heat(n: pypsa.Network, costs: pd.DataFrame, cop: xr.DataArray): p_set=heat_load.loc[n.snapshots], ) + if options["residential_heat_dsm"] and heat_system in [ + HeatSystem.RESIDENTIAL_RURAL, + HeatSystem.RESIDENTIAL_URBAN_DECENTRAL, + HeatSystem.URBAN_CENTRAL, + ]: + factor = heat_system.heat_demand_weighting( + urban_fraction=urban_fraction[nodes], dist_fraction=dist_fraction[nodes] + ) + + heat_dsm_profile = pd.read_csv( + snakemake.input.heat_dsm_profile, header=[1], index_col=[0] + )[nodes] + heat_dsm_profile.index = n.snapshots + + e_nom = ( + heat_demand[["residential space"]] + .T.groupby(level=1) + .sum() + .T[nodes] + .multiply(factor) + ) + + heat_dsm_profile = ( + heat_dsm_profile * options["residential_heat_restriction_value"] + ) + e_nom = e_nom.max() + + # Thermal (standing) losses of buildings assumed to be the same as decentralized water tanks + tes_time_constant_days = options["tes_tau"]["decentral"] + + n.madd( + "Store", + nodes, + suffix=f" {heat_system} heat flexibility", + bus=nodes + f" {heat_system} heat", + carrier="residential heating flexibility", + standing_loss=1 - np.exp(-1 / 24 / tes_time_constant_days), + e_cyclic=True, + e_nom=e_nom, + e_max_pu=heat_dsm_profile, + ) + + logger.info(f"adding heat dsm in {heat_system} heating.") + ## Add heat pumps for heat_source in snakemake.params.heat_pump_sources[ heat_system.system_type.value