diff --git a/.gitignore b/.gitignore index 95ba5524..21062dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: CC0-1.0 +master + .snakemake* .ipynb_checkpoints __pycache__ diff --git a/config/config.default.yaml b/config/config.default.yaml index 18a095df..3e6cc138 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -431,7 +431,6 @@ sector: bev_availability: 0.5 bev_energy: 0.05 bev_charge_efficiency: 0.9 - bev_plug_to_wheel_efficiency: 0.2 bev_charge_rate: 0.011 bev_avail_max: 0.95 bev_avail_mean: 0.8 @@ -460,8 +459,9 @@ sector: 2040: 0.3 2045: 0.15 2050: 0 - transport_fuel_cell_efficiency: 0.5 - transport_internal_combustion_efficiency: 0.3 + transport_electric_efficiency: 53.19 # 1 MWh_el = 53.19*100 km + transport_fuel_cell_efficiency: 30.003 # 1 MWh_H2 = 30.003*100 km + transport_ice_efficiency: 16.0712 # 1 MWh_oil = 16.0712 * 100 km agriculture_machinery_electric_share: 0 agriculture_machinery_oil_share: 1 agriculture_machinery_fuel_efficiency: 0.7 @@ -1041,6 +1041,7 @@ plotting: BEV charger: '#baf238' V2G: '#e5ffa8' land transport EV: '#baf238' + land transport demand: '#38baf2' Li ion: '#baf238' # hot water storage water tanks: '#e69487' diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index 58ccd9bf..703652b5 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -24,7 +24,6 @@ bev_dsm,--,"{true, false}",Add the option for battery electric vehicles (BEV) to bev_availability,--,float,The share for battery electric vehicles (BEV) that are able to do demand side management (DSM) bev_energy,--,float,The average size of battery electric vehicles (BEV) in MWh bev_charge_efficiency,--,float,Battery electric vehicles (BEV) charge and discharge efficiency -bev_plug_to_wheel _efficiency,km/kWh,float,The distance battery electric vehicles (BEV) can travel in km per kWh of energy charge in battery. Base value comes from `Tesla Model S `_ bev_charge_rate,MWh,float,The power consumption for one electric vehicle (EV) in MWh. Value derived from 3-phase charger with 11 kW. bev_avail_max,--,float,The maximum share plugged-in availability for passenger electric vehicles. bev_avail_mean,--,float,The average share plugged-in availability for passenger electric vehicles. @@ -32,8 +31,9 @@ v2g,--,"{true, false}",Allows feed-in to grid from EV battery land_transport_fuel_cell _share,--,Dictionary with planning horizons as keys.,The share of vehicles that uses fuel cells in a given year land_transport_electric _share,--,Dictionary with planning horizons as keys.,The share of vehicles that uses electric vehicles (EV) in a given year land_transport_ice _share,--,Dictionary with planning horizons as keys.,The share of vehicles that uses internal combustion engines (ICE) in a given year. What is not EV or FCEV is oil-fuelled ICE. -transport_fuel_cell _efficiency,--,float,The H2 conversion efficiencies of fuel cells in transport -transport_internal _combustion_efficiency,--,float,The oil conversion efficiencies of internal combustion engine (ICE) in transport +transport_electric_efficiency,MWh/100km,float,The conversion efficiencies of electric vehicles in transport +transport_fuel_cell_efficiency,MWh/100km,float,The H2 conversion efficiencies of fuel cells in transport +transport_ice_efficiency,MWh/100km,float,The oil conversion efficiencies of internal combustion engine (ICE) in transport agriculture_machinery _electric_share,--,float,The share for agricultural machinery that uses electricity agriculture_machinery _oil_share,--,float,The share for agricultural machinery that uses oil agriculture_machinery _fuel_efficiency,--,float,The efficiency of electric-powered machinery in the conversion of electricity to meet agricultural needs. diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 2588ecc7..333cbafd 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -81,6 +81,9 @@ Upcoming Release * bugfix: convert Strings to pathlib.Path objects as input to ConfigSettings +* bugfix: fix distinction of temperature-dependent correction factors for the + energy demand of electric vehicles, ICES fuel cell cars. + * Allow the use of more solvers in clustering (Xpress, COPT, Gurobi, CPLEX, SCIP, MOSEK). * Enhanced support for choosing different weather years diff --git a/scripts/build_energy_totals.py b/scripts/build_energy_totals.py index e32a2193..f1d0e661 100644 --- a/scripts/build_energy_totals.py +++ b/scripts/build_energy_totals.py @@ -396,13 +396,12 @@ def build_idees(countries): names=["country", "year"], ) + # efficiency kgoe/100km -> ktoe/100km + totals.loc[:, "passenger car efficiency"] *= 1e3 # convert ktoe to TWh exclude = totals.columns.str.fullmatch("passenger cars") totals.loc[:, ~exclude] *= 11.63 / 1e3 - # convert TWh/100km to kWh/km - totals.loc[:, "passenger car efficiency"] *= 10 - return totals diff --git a/scripts/build_transport_demand.py b/scripts/build_transport_demand.py index 35f22a80..a052581b 100644 --- a/scripts/build_transport_demand.py +++ b/scripts/build_transport_demand.py @@ -24,14 +24,17 @@ logger = logging.getLogger(__name__) def build_nodal_transport_data(fn, pop_layout, year): + # get numbers of car and fuel efficiency per country transport_data = pd.read_csv(fn, index_col=[0, 1]) transport_data = transport_data.xs(min(2015, year), level="year") + # break number of cars down to nodal level based on population density nodal_transport_data = transport_data.loc[pop_layout.ct].fillna(0.0) nodal_transport_data.index = pop_layout.index nodal_transport_data["number cars"] = ( pop_layout["fraction"] * nodal_transport_data["number cars"] ) + # fill missing fuel efficiency with average data nodal_transport_data.loc[ nodal_transport_data["average fuel efficiency"] == 0.0, "average fuel efficiency", @@ -41,10 +44,13 @@ def build_nodal_transport_data(fn, pop_layout, year): def build_transport_demand(traffic_fn, airtemp_fn, nodes, nodal_transport_data): - ## Get overall demand curve for all vehicles - + """ + Returns transport demand per bus in unit km driven [100 km]. + """ + # averaged weekly counts from the year 2010-2015 traffic = pd.read_csv(traffic_fn, skiprows=2, usecols=["count"]).squeeze("columns") + # create annual profile take account time zone + summer time transport_shape = generate_periodic_profiles( dt_index=snapshots, nodes=nodes, @@ -52,15 +58,6 @@ def build_transport_demand(traffic_fn, airtemp_fn, nodes, nodal_transport_data): ) transport_shape = transport_shape / transport_shape.sum() - # electric motors are more efficient, so alter transport demand - - plug_to_wheels_eta = options["bev_plug_to_wheel_efficiency"] - battery_to_wheels_eta = plug_to_wheels_eta * options["bev_charge_efficiency"] - - efficiency_gain = ( - nodal_transport_data["average fuel efficiency"] / battery_to_wheels_eta - ) - # get heating demand for correction to demand time series temperature = xr.open_dataarray(airtemp_fn).to_pandas() @@ -73,16 +70,7 @@ def build_transport_demand(traffic_fn, airtemp_fn, nodes, nodal_transport_data): options["ICE_upper_degree_factor"], ) - dd_EV = transport_degree_factor( - temperature, - options["transport_heating_deadband_lower"], - options["transport_heating_deadband_upper"], - options["EV_lower_degree_factor"], - options["EV_upper_degree_factor"], - ) - # divide out the heating/cooling demand from ICE totals - # and multiply back in the heating/cooling demand for EVs ice_correction = (transport_shape * (1 + dd_ICE)).sum() / transport_shape.sum() energy_totals_transport = ( @@ -91,10 +79,11 @@ def build_transport_demand(traffic_fn, airtemp_fn, nodes, nodal_transport_data): - pop_weighted_energy_totals["electricity rail"] ) - return ( - (transport_shape.multiply(energy_totals_transport) * 1e6 * nyears) - .divide(efficiency_gain * ice_correction) - .multiply(1 + dd_EV) + # convert average fuel efficiency from kW/100 km -> MW/100km + eff = nodal_transport_data["average fuel efficiency"] / 1e3 + + return (transport_shape.multiply(energy_totals_transport) * 1e6 * nyears).divide( + eff * ice_correction ) @@ -131,11 +120,14 @@ def bev_availability_profile(fn, snapshots, nodes, options): """ Derive plugged-in availability for passenger electric vehicles. """ + # car count in typical week traffic = pd.read_csv(fn, skiprows=2, usecols=["count"]).squeeze("columns") - + # maximum share plugged-in availability for passenger electric vehicles avail_max = options["bev_avail_max"] + # average share plugged-in availability for passenger electric vehicles avail_mean = options["bev_avail_mean"] + # linear scaling, highest when traffic is lowest, decreases if traffic increases avail = avail_max - (avail_max - avail_mean) * (traffic - traffic.min()) / ( traffic.mean() - traffic.min() ) @@ -156,6 +148,8 @@ def bev_availability_profile(fn, snapshots, nodes, options): def bev_dsm_profile(snapshots, nodes, options): dsm_week = np.zeros((24 * 7,)) + # assuming that at a certain time ("bev_dsm_restriction_time") EVs have to + # be charged to a minimum value (defined in bev_dsm_restriction_value) dsm_week[(np.arange(0, 7, 1) * 24 + options["bev_dsm_restriction_time"])] = options[ "bev_dsm_restriction_value" ] @@ -167,6 +161,7 @@ def bev_dsm_profile(snapshots, nodes, options): ) +# %% if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake @@ -174,7 +169,7 @@ if __name__ == "__main__": snakemake = mock_snakemake( "build_transport_demand", simpl="", - clusters=60, + clusters=37, ) configure_logging(snakemake) set_scenario_config(snakemake) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index dbccf90b..2d0e560e 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -29,6 +29,7 @@ from build_energy_totals import ( build_eurostat, build_eurostat_co2, ) +from build_transport_demand import transport_degree_factor from networkx.algorithms import complement from networkx.algorithms.connectivity.edge_augmentation import k_edge_augmentation from prepare_network import maybe_adjust_costs_and_potentials @@ -1501,11 +1502,224 @@ def add_storage_and_grids(n, costs): ) +def check_land_transport_shares(shares): + # Sums up the shares, ignoring None values + total_share = sum(filter(None, shares)) + if total_share != 1: + logger.warning( + f"Total land transport shares sum up to {total_share:.2%}," + "corresponding to increased or decreased demand assumptions." + ) + + +def get_temp_efficency( + car_efficiency, + temperature, + deadband_lw, + deadband_up, + degree_factor_lw, + degree_factor_up, +): + """ + Correct temperature depending on heating and cooling for respective car + type. + """ + # temperature correction for EVs + dd = transport_degree_factor( + temperature, + deadband_lw, + deadband_up, + degree_factor_lw, + degree_factor_up, + ) + + temp_eff = 1 / (1 + dd) + + return car_efficiency * temp_eff + + +def add_EVs( + n, + avail_profile, + dsm_profile, + p_set, + electric_share, + number_cars, + temperature, +): + + n.add("Carrier", "Li ion") + + n.madd( + "Bus", + spatial.nodes, + suffix=" EV battery", + location=spatial.nodes, + carrier="Li ion", + unit="MWh_el", + ) + + car_efficiency = options["transport_electric_efficiency"] + + # temperature corrected efficiency + efficiency = get_temp_efficency( + car_efficiency, + temperature, + options["transport_heating_deadband_lower"], + options["transport_heating_deadband_upper"], + options["EV_lower_degree_factor"], + options["EV_upper_degree_factor"], + ) + + p_shifted = (p_set + cycling_shift(p_set, 1) + cycling_shift(p_set, 2)) / 3 + + cyclic_eff = p_set.div(p_shifted) + + efficiency *= cyclic_eff + + profile = electric_share * p_set.div(efficiency) + + n.madd( + "Load", + spatial.nodes, + suffix=" land transport EV", + bus=spatial.nodes + " EV battery", + carrier="land transport EV", + p_set=profile, + ) + + p_nom = number_cars * options.get("bev_charge_rate", 0.011) * electric_share + + n.madd( + "Link", + spatial.nodes, + suffix=" BEV charger", + bus0=spatial.nodes, + bus1=spatial.nodes + " EV battery", + p_nom=p_nom, + carrier="BEV charger", + p_max_pu=avail_profile[spatial.nodes], + lifetime=1, + efficiency=options.get("bev_charge_efficiency", 0.9), + ) + + if options["v2g"]: + n.madd( + "Link", + spatial.nodes, + suffix=" V2G", + bus1=spatial.nodes, + bus0=spatial.nodes + " EV battery", + p_nom=p_nom, + carrier="V2G", + p_max_pu=avail_profile[spatial.nodes], + lifetime=1, + efficiency=options.get("bev_charge_efficiency", 0.9), + ) + + if options["bev_dsm"]: + e_nom = ( + number_cars + * options.get("bev_energy", 0.05) + * options["bev_availability"] + * electric_share + ) + + n.madd( + "Store", + spatial.nodes, + suffix=" battery storage", + bus=spatial.nodes + " EV battery", + carrier="battery storage", + e_cyclic=True, + e_nom=e_nom, + e_max_pu=1, + e_min_pu=dsm_profile[spatial.nodes], + ) + + +def add_fuel_cell_cars(n, p_set, fuel_cell_share, temperature): + + car_efficiency = options["transport_fuel_cell_efficiency"] + + # temperature corrected efficiency + efficiency = get_temp_efficency( + car_efficiency, + temperature, + options["transport_heating_deadband_lower"], + options["transport_heating_deadband_upper"], + options["ICE_lower_degree_factor"], + options["ICE_upper_degree_factor"], + ) + + profile = fuel_cell_share * p_set.div(efficiency) + + n.madd( + "Load", + spatial.nodes, + suffix=" land transport fuel cell", + bus=spatial.h2.nodes, + carrier="land transport fuel cell", + p_set=profile, + ) + + +def add_ice_cars(n, p_set, ice_share, temperature): + + add_carrier_buses(n, "oil") + + car_efficiency = options["transport_ice_efficiency"] + + # temperature corrected efficiency + efficiency = get_temp_efficency( + car_efficiency, + temperature, + options["transport_heating_deadband_lower"], + options["transport_heating_deadband_upper"], + options["ICE_lower_degree_factor"], + options["ICE_upper_degree_factor"], + ) + + profile = ice_share * p_set.div(efficiency).rename( + columns=lambda x: x + " land transport oil" + ) + + if not options["regional_oil_demand"]: + profile = profile.sum(axis=1).to_frame(name="EU land transport oil") + + n.madd( + "Bus", + spatial.oil.land_transport, + location=spatial.oil.demand_locations, + carrier="land transport oil", + unit="land transport", + ) + + n.madd( + "Load", + spatial.oil.land_transport, + bus=spatial.oil.land_transport, + carrier="land transport oil", + p_set=profile, + ) + + n.madd( + "Link", + spatial.oil.land_transport, + bus0=spatial.oil.nodes, + bus1=spatial.oil.land_transport, + bus2="co2 atmosphere", + carrier="land transport oil", + efficiency2=costs.at["oil", "CO2 intensity"], + p_nom_extendable=True, + ) + + def add_land_transport(n, costs): - # TODO options? logger.info("Add land transport") + # read in transport demand in units driven km [100 km] transport = pd.read_csv( snakemake.input.transport_demand, index_col=0, parse_dates=True ) @@ -1519,158 +1733,36 @@ def add_land_transport(n, costs): snakemake.input.dsm_profile, index_col=0, parse_dates=True ) - fuel_cell_share = get(options["land_transport_fuel_cell_share"], investment_year) - electric_share = get(options["land_transport_electric_share"], investment_year) - ice_share = get(options["land_transport_ice_share"], investment_year) + # exogenous share of passenger car type + engine_types = ["fuel_cell", "electric", "ice"] + shares = pd.Series() + for engine in engine_types: + shares[engine] = get(options[f"land_transport_{engine}_share"], investment_year) + logger.info(f"{engine} share: {shares[engine]*100}%") - total_share = fuel_cell_share + electric_share + ice_share - if total_share != 1: - logger.warning( - f"Total land transport shares sum up to {total_share:.2%}, corresponding to increased or decreased demand assumptions." + check_land_transport_shares(shares) + + p_set = transport[spatial.nodes] + + # temperature for correction factor for heating/cooling + temperature = xr.open_dataarray(snakemake.input.temp_air_total).to_pandas() + + if shares["electric"] > 0: + add_EVs( + n, + avail_profile, + dsm_profile, + p_set, + shares["electric"], + number_cars, + temperature, ) - logger.info(f"FCEV share: {fuel_cell_share*100}%") - logger.info(f"EV share: {electric_share*100}%") - logger.info(f"ICEV share: {ice_share*100}%") + if shares["fuel_cell"] > 0: + add_fuel_cell_cars(n, p_set, shares["fuel_cell"], temperature) - nodes = pop_layout.index - - if electric_share > 0: - n.add("Carrier", "Li ion") - - n.madd( - "Bus", - nodes, - suffix=" EV battery", - location=nodes, - carrier="Li ion", - unit="MWh_el", - ) - - p_set = ( - electric_share - * ( - transport[nodes] - + cycling_shift(transport[nodes], 1) - + cycling_shift(transport[nodes], 2) - ) - / 3 - ) - - n.madd( - "Load", - nodes, - suffix=" land transport EV", - bus=nodes + " EV battery", - carrier="land transport EV", - p_set=p_set, - ) - - p_nom = number_cars * options.get("bev_charge_rate", 0.011) * electric_share - - n.madd( - "Link", - nodes, - suffix=" BEV charger", - bus0=nodes, - bus1=nodes + " EV battery", - p_nom=p_nom, - carrier="BEV charger", - p_max_pu=avail_profile[nodes], - efficiency=options.get("bev_charge_efficiency", 0.9), - # These were set non-zero to find LU infeasibility when availability = 0.25 - # p_nom_extendable=True, - # p_nom_min=p_nom, - # capital_cost=1e6, #i.e. so high it only gets built where necessary - ) - - if electric_share > 0 and options["v2g"]: - n.madd( - "Link", - nodes, - suffix=" V2G", - bus1=nodes, - bus0=nodes + " EV battery", - p_nom=p_nom, - carrier="V2G", - p_max_pu=avail_profile[nodes], - efficiency=options.get("bev_charge_efficiency", 0.9), - ) - - if electric_share > 0 and options["bev_dsm"]: - e_nom = ( - number_cars - * options.get("bev_energy", 0.05) - * options["bev_availability"] - * electric_share - ) - - n.madd( - "Store", - nodes, - suffix=" battery storage", - bus=nodes + " EV battery", - carrier="battery storage", - e_cyclic=True, - e_nom=e_nom, - e_max_pu=1, - e_min_pu=dsm_profile[nodes], - ) - - if fuel_cell_share > 0: - n.madd( - "Load", - nodes, - suffix=" land transport fuel cell", - bus=nodes + " H2", - carrier="land transport fuel cell", - p_set=fuel_cell_share - / options["transport_fuel_cell_efficiency"] - * transport[nodes], - ) - - if ice_share > 0: - add_carrier_buses(n, "oil") - - ice_efficiency = options["transport_internal_combustion_efficiency"] - - p_set_land_transport_oil = ( - ice_share - / ice_efficiency - * transport[nodes].rename(columns=lambda x: x + " land transport oil") - ) - - if not options["regional_oil_demand"]: - p_set_land_transport_oil = p_set_land_transport_oil.sum(axis=1).to_frame( - name="EU land transport oil" - ) - - n.madd( - "Bus", - spatial.oil.land_transport, - location=spatial.oil.demand_locations, - carrier="land transport oil", - unit="land transport", - ) - - n.madd( - "Load", - spatial.oil.land_transport, - bus=spatial.oil.land_transport, - carrier="land transport oil", - p_set=p_set_land_transport_oil, - ) - - n.madd( - "Link", - spatial.oil.land_transport, - bus0=spatial.oil.nodes, - bus1=spatial.oil.land_transport, - bus2="co2 atmosphere", - carrier="land transport oil", - efficiency2=costs.at["oil", "CO2 intensity"], - p_nom_extendable=True, - ) + if shares["ice"] > 0: + add_ice_cars(n, p_set, shares["ice"], temperature) def build_heat_demand(n): @@ -3666,19 +3758,20 @@ def lossy_bidirectional_links(n, carrier, efficiencies={}): ) +# %% if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake snakemake = mock_snakemake( "prepare_sector_network", - configfiles="test/config.overnight.yaml", + # configfiles="test/config.overnight.yaml", simpl="", opts="", clusters="37", ll="v1.0", - sector_opts="CO2L0-24h-T-H-B-I-A-dist1", - planning_horizons="2030", + sector_opts="730H-T-H-B-I-A-dist1", + planning_horizons="2050", ) configure_logging(snakemake)