diff --git a/Snakefile b/Snakefile index 9efde6f0..f4c795f3 100644 --- a/Snakefile +++ b/Snakefile @@ -159,6 +159,7 @@ rule build_energy_totals: co2="data/eea/UNFCCC_v23.csv", swiss="data/switzerland-sfoe/switzerland-new_format.csv", idees="data/jrc-idees-2015", + district_heat_share='data/district_heat_share.csv', eurostat=input_eurostat output: energy_name='resources/energy_totals.csv', diff --git a/config.default.yaml b/config.default.yaml index bf53b457..d6d4052c 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -141,8 +141,16 @@ existing_capacities: sector: - central: true - central_fraction: 0.6 + district_heating: + potential: 0.6 # maximum fraction of urban demand which can be supplied by district heating + # increase of today's district heating demand to potential maximum district heating share + # progress = 0 means today's district heating share, progress = 1 means maximum fraction of urban demand is supplied by district heating + progress: + 2020: 0.0 + 2030: 0.3 + 2040: 0.6 + 2050: 1.0 + district_heating_loss: 0.15 bev_dsm_restriction_value: 0.75 #Set to 0 for no restriction on BEV DSM bev_dsm_restriction_time: 7 #Time at which SOC of BEV has to be dsm_restriction_value transport_heating_deadband_upper: 20. @@ -151,7 +159,6 @@ sector: ICE_upper_degree_factor: 1.6 EV_lower_degree_factor: 0.98 EV_upper_degree_factor: 0.63 - district_heating_loss: 0.15 bev_dsm: true #turns on EV battery bev_availability: 0.5 #How many cars do smart charging bev_energy: 0.05 #average battery size in MWh diff --git a/data/district_heat_share.csv b/data/district_heat_share.csv new file mode 100644 index 00000000..5afd65c8 --- /dev/null +++ b/data/district_heat_share.csv @@ -0,0 +1,34 @@ +country,share to satisfy heat demand (residential) in percent,capacity[MWth] +AT,14,11200 +BG,16,6162 +BA,8, +HR,6.3,2221 +CZ,40, +DK,65, +FI,38,23390 +FR,5, +DE,13.8, +HU,7.92875588637399,8549 +IS,90,8079000 +IE,0.8, +IT,3,8727 +LV,73,2254 +LT,56, +MK,23.7745607009008,636 +NO,4,3400 +PL,42,54912 +PT,0.070754716981132,34 +RS,25,5821 +SI,8.86,1739 +ES,0.251589260787732,1273 +SE,50.4, +UK,2, +BY,70, +EE,52,5406 +KO,3,207 +RO,23,9962 +SK,54,15000 +NL,4,9800 +CH,4,2792 +AL,0, +ME,0, diff --git a/doc/data.csv b/doc/data.csv index 26a5bea0..01fa04b3 100644 --- a/doc/data.csv +++ b/doc/data.csv @@ -25,3 +25,6 @@ Comparative level investment,comparative_level_investment.csv,Eurostat,https://e Electricity taxes,electricity_taxes_eu.csv,Eurostat,https://appsso.eurostat.ec.europa.eu/nui/show.do?dataset=nrg_pc_204&lang=en Building topologies and corresponding standard values,tabula-calculator-calcsetbuilding.csv,unknown,https://episcope.eu/fileadmin/tabula/public/calc/tabula-calculator.xlsx Retrofitting thermal envelope costs for Germany,retro_cost_germany.csv,unkown,https://www.iwu.de/forschung/handlungslogiken/kosten-energierelevanter-bau-und-anlagenteile-bei-modernisierung/ +District heating most countries,jrc-idees-2015/,CC BY 4.0,https://ec.europa.eu/jrc/en/potencia/jrc-idees,, +District heating missing countries,district_heat_share.csv,unkown,https://www.euroheat.org/knowledge-hub/country-profiles,, + diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 16889268..eb3f7a18 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -88,6 +88,7 @@ Future release * Compatibility with ``xarray`` version 0.19. * Separate basic chemicals into HVC, chlorine, methanol and ammonia [`#166 `_]. * Add option to specify reuse, primary production, and mechanical and chemical recycling fraction of platics [`#166 `_]. +* Include today's district heating shares in myopic optimisation and add option to specify exogenous path for district heating share increase under ``sector: district_heating:`` [`#149 `_]. PyPSA-Eur-Sec 0.5.0 (21st May 2021) =================================== diff --git a/scripts/build_energy_totals.py b/scripts/build_energy_totals.py index aec1c61b..efb5d6da 100644 --- a/scripts/build_energy_totals.py +++ b/scripts/build_energy_totals.py @@ -212,6 +212,12 @@ def idees_per_country(ct, year): assert df.index[47] == "Electricity" ct_totals["electricity residential"] = df[47] + assert df.index[46] == "Derived heat" + ct_totals["Derived heat residential"] = df[46] + + assert df.index[50] == 'Thermal uses' + ct_totals["thermal uses residential"] = df[50] + # services df = pd.read_excel(fn_services, "SER_hh_fec", index_col=0)[year] @@ -239,6 +245,12 @@ def idees_per_country(ct, year): assert df.index[50] == "Electricity" ct_totals["electricity services"] = df[50] + assert df.index[49] == "Derived heat" + ct_totals["derived heat services"] = df[49] + + assert df.index[53] == 'Thermal uses' + ct_totals["thermal uses services"] = df[53] + # transport df = pd.read_excel(fn_transport, "TrRoad_ene", index_col=0)[year] @@ -342,6 +354,7 @@ def build_idees(countries, year): with mp.Pool(processes=nprocesses) as pool: totals_list = list(tqdm(pool.imap(func, countries), **tqdm_kwargs)) + totals = pd.concat(totals_list, axis=1) # convert ktoe to TWh @@ -351,6 +364,13 @@ def build_idees(countries, year): # convert TWh/100km to kWh/km totals.loc["passenger car efficiency"] *= 10 + # district heating share + district_heat = totals.loc[["derived heat residential", + "derived heat services"]].sum() + total_heat = totals.loc[["thermal uses residential", + "thermal uses services"]].sum() + totals.loc["district heat share"] = district_heat.div(total_heat) + return totals.T @@ -493,7 +513,7 @@ def build_energy_totals(countries, eurostat, swiss, idees): for purpose in ["passenger", "freight"]: attrs = [f"total domestic aviation {purpose}", f"total international aviation {purpose}"] - df.loc[missing, f"total aviation {purpose}"] = df.loc[missing, attrs].sum(axis=1) + df.loc[missing, f"total aviation {purpose}"] = df.loc[missing, attrs].sum(axis=1) if "BA" in df.index: # fill missing data for BA (services and road energy data) @@ -502,6 +522,14 @@ def build_energy_totals(countries, eurostat, swiss, idees): ratio = df.at["BA", "total residential"] / df.at["RS", "total residential"] df.loc['BA', missing] = ratio * df.loc["RS", missing] + # Missing district heating share + dh_share = pd.read_csv(snakemake.input.district_heat_share, + index_col=0, usecols=[0, 1]) + # make conservative assumption and take minimum from both data sets + df["district heat share"] = (pd.concat([df["district heat share"], + dh_share.reindex(index=df.index)/100], + axis=1).min(axis=1)) + return df diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 5f4b680e..5fc4a6e3 100644 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -489,8 +489,7 @@ def add_dac(n, costs): efficiency3 = -(costs.at['direct air capture', 'heat-input'] - costs.at['direct air capture', 'compression-heat-output']) n.madd("Link", - locations, - suffix=" DAC", + heat_buses.str.replace(" heat", " DAC"), bus0="co2 atmosphere", bus1=spatial.co2.df.loc[locations, "nodes"].values, bus2=locations.values, @@ -636,6 +635,8 @@ def prepare_data(n): nodal_energy_totals = energy_totals.loc[pop_layout.ct].fillna(0.) nodal_energy_totals.index = pop_layout.index + # district heat share not weighted by population + district_heat_share = nodal_energy_totals["district heat share"].round(2) nodal_energy_totals = nodal_energy_totals.multiply(pop_layout.fraction, axis=0) # copy forward the daily average heat demand into each hour, so it can be multipled by the intraday profile @@ -758,7 +759,7 @@ def prepare_data(n): ) - return nodal_energy_totals, heat_demand, ashp_cop, gshp_cop, solar_thermal, transport, avail_profile, dsm_profile, nodal_transport_data + return nodal_energy_totals, heat_demand, ashp_cop, gshp_cop, solar_thermal, transport, avail_profile, dsm_profile, nodal_transport_data, district_heat_share # TODO checkout PyPSA-Eur script @@ -1336,11 +1337,10 @@ def add_heat(n, costs): sectors = ["residential", "services"] - nodes = create_nodes_for_heat_sector() - #NB: must add costs of central heating afterwards (EUR 400 / kWpeak, 50a, 1% FOM from Fraunhofer ISE) + nodes, dist_fraction, urban_fraction = create_nodes_for_heat_sector() - urban_fraction = options['central_fraction'] * pop_layout["urban"] / pop_layout[["urban", "rural"]].sum(axis=1) + #NB: must add costs of central heating afterwards (EUR 400 / kWpeak, 50a, 1% FOM from Fraunhofer ISE) # exogenously reduce space heat demand if options["reduce_space_heat_exogenously"]: @@ -1372,15 +1372,22 @@ def add_heat(n, costs): ## Add heat load for sector in sectors: + # heat demand weighting if "rural" in name: factor = 1 - urban_fraction[nodes[name]] - elif "urban" in name: - factor = urban_fraction[nodes[name]] + elif "urban central" in name: + factor = dist_fraction[nodes[name]] + elif "urban decentral" in name: + factor = urban_fraction[nodes[name]] - \ + dist_fraction[nodes[name]] + else: + raise NotImplementedError(f" {name} not in " f"heat systems: {heat_systems}") + if sector in name: heat_load = heat_demand[[sector + " water",sector + " space"]].groupby(level=1,axis=1).sum()[nodes[name]].multiply(factor) if name == "urban central": - heat_load = heat_demand.groupby(level=1,axis=1).sum()[nodes[name]].multiply(urban_fraction[nodes[name]] * (1 + options['district_heating_loss'])) + heat_load = heat_demand.groupby(level=1,axis=1).sum()[nodes[name]].multiply(factor * (1 + options['district_heating']['district_heating_loss'])) n.madd("Load", nodes[name], @@ -1661,23 +1668,39 @@ def create_nodes_for_heat_sector(): # urban are areas with high heating density # urban can be split into district heating (central) and individual heating (decentral) + ct_urban = pop_layout.urban.groupby(pop_layout.ct).sum() + # distribution of urban population within a country + pop_layout["urban_ct_fraction"] = pop_layout.urban / pop_layout.ct.map(ct_urban.get) + sectors = ["residential", "services"] nodes = {} + urban_fraction = pop_layout.urban / pop_layout[["rural", "urban"]].sum(axis=1) + for sector in sectors: nodes[sector + " rural"] = pop_layout.index + nodes[sector + " urban decentral"] = pop_layout.index + + # maximum potential of urban demand covered by district heating + central_fraction = options['district_heating']["potential"] + # district heating share at each node + dist_fraction_node = district_heat_share * pop_layout["urban_ct_fraction"] / pop_layout["fraction"] + nodes["urban central"] = dist_fraction_node.index + # if district heating share larger than urban fraction -> set urban + # fraction to district heating share + urban_fraction = pd.concat([urban_fraction, dist_fraction_node], + axis=1).max(axis=1) + # difference of max potential and today's share of district heating + diff = (urban_fraction * central_fraction) - dist_fraction_node + progress = get(options["district_heating"]["potential"], investment_year) + dist_fraction_node += diff * progress + print( + "The current district heating share compared to the maximum", + f"possible is increased by a progress factor of\n{progress}", + f"resulting in a district heating share of\n{dist_fraction_node}" + ) - if options["central"]: - # TODO: this looks hardcoded, move to config - urban_decentral_ct = pd.Index(["ES", "GR", "PT", "IT", "BG"]) - nodes[sector + " urban decentral"] = pop_layout.index[pop_layout.ct.isin(urban_decentral_ct)] - else: - nodes[sector + " urban decentral"] = pop_layout.index - - # for central nodes, residential and services are aggregated - nodes["urban central"] = pop_layout.index.symmetric_difference(nodes["residential urban decentral"]) - - return nodes + return nodes, dist_fraction_node, urban_fraction def add_biomass(n, costs): @@ -1993,7 +2016,7 @@ def add_industry(n, costs): if options["oil_boilers"]: - nodes_heat = create_nodes_for_heat_sector() + nodes_heat = create_nodes_for_heat_sector()[0] for name in ["residential rural", "services rural", "residential urban decentral", "services urban decentral"]: @@ -2191,18 +2214,18 @@ def limit_individual_line_extension(n, maxext): hvdc = n.links.index[n.links.carrier == 'DC'] n.links.loc[hvdc, 'p_nom_max'] = n.links.loc[hvdc, 'p_nom'] + maxext - +#%% if __name__ == "__main__": if 'snakemake' not in globals(): from helper import mock_snakemake snakemake = mock_snakemake( 'prepare_sector_network', simpl='', - clusters="45", + opts="", + clusters="37", lv=1.0, - opts='', sector_opts='Co2L0-168H-T-H-B-I-solar3-dist1', - planning_horizons="2030", + planning_horizons="2020", ) logging.basicConfig(level=snakemake.config['logging_level']) @@ -2254,10 +2277,10 @@ def limit_individual_line_extension(n, maxext): if o == "biomasstransport": options["biomass_transport"] = True - nodal_energy_totals, heat_demand, ashp_cop, gshp_cop, solar_thermal, transport, avail_profile, dsm_profile, nodal_transport_data = prepare_data(n) + nodal_energy_totals, heat_demand, ashp_cop, gshp_cop, solar_thermal, transport, avail_profile, dsm_profile, nodal_transport_data, district_heat_share = prepare_data(n) if "nodistrict" in opts: - options["central"] = False + options["district_heating"]["progress"] = 0.0 if "T" in opts: add_land_transport(n, costs)