diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cd8bd4..92e98129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ * **ADD** fully-electrified heat demand (#284). -* **ADD** fully-electrified road transportation (#270, #271, #358). A parameter allows to define the share of uncontrolled (timeseries) vs controlled charging (optimised) by the solver (#338). +* **ADD** fully-electrified road transportation (#270, #271, #358). A parameter allows to define the share of uncontrolled (timeseries) vs controlled charging (optimised) by the solver (#338). Data for controlled charging constraints is readily available (#356), but corresponding constraints are not yet implemented (#385). * **ADD** nuclear power plant technology with capacity limits. Capacity limits can be equal to today or be bound by a minimum and maximum capacity to represent an available range in future. In either case, capacities are allocated at a subnational resolution based on linear scaling from current capacity geolocations, using the JRC power plant database (#78). diff --git a/Snakefile b/Snakefile index a69c4258..e1af8905 100644 --- a/Snakefile +++ b/Snakefile @@ -45,7 +45,7 @@ wildcard_constraints: ruleorder: area_to_capacity_limits > hydro_capacities > biofuels > nuclear_regional_capacity > dummy_tech_locations_template ruleorder: bio_techs_and_locations_template > techs_and_locations_template -ruleorder: create_controlled_road_transport_annual_demand > dummy_tech_locations_template +ruleorder: create_controlled_road_transport_annual_demand_and_installed_capacities > dummy_tech_locations_template ALL_CF_TECHNOLOGIES = [ "wind-onshore", "wind-offshore", "open-field-pv", @@ -188,6 +188,10 @@ rule model_template: "build/models/{resolution}/timeseries/demand/uncontrolled-road-transport-historic-electrification.csv", "build/models/{resolution}/timeseries/demand/electrified-heat-demand.csv", "build/models/{resolution}/timeseries/demand/heat-demand-historic-electrification.csv", + "build/models/{resolution}/timeseries/demand/demand-shape-min-ev.csv", + "build/models/{resolution}/timeseries/demand/demand-shape-max-ev.csv", + "build/models/{resolution}/timeseries/demand/demand-shape-equals-ev.csv", + "build/models/{resolution}/timeseries/demand/plugin-profiles-ev.csv", ), optional_input_files = lambda wildcards: expand( f"build/models/{wildcards.resolution}/{{input_file}}", diff --git a/config/default.yaml b/config/default.yaml index 3ee2db6b..d4450bf4 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -19,7 +19,7 @@ data-sources: swiss-end-use: https://www.bfe.admin.ch/bfe/en/home/versorgung/statistik-und-geodaten/energiestatistiken/energieverbrauch-nach-verwendungszweck.exturl.html/aHR0cHM6Ly9wdWJkYi5iZmUuYWRtaW4uY2gvZGUvcHVibGljYX/Rpb24vZG93bmxvYWQvOTg1NA==.html swiss-energy-balance: https://www.bfe.admin.ch/bfe/en/home/versorgung/statistik-und-geodaten/energiestatistiken/gesamtenergiestatistik.exturl.html/aHR0cHM6Ly9wdWJkYi5iZmUuYWRtaW4uY2gvZGUvcHVibGljYX/Rpb24vZG93bmxvYWQvNzUxOQ==.html swiss-industry-energy-balance: https://www.bfe.admin.ch/bfe/en/home/versorgung/statistik-und-geodaten/energiestatistiken/teilstatistiken.exturl.html/aHR0cHM6Ly9wdWJkYi5iZmUuYWRtaW4uY2gvZGUvcHVibGljYX/Rpb24vZG93bmxvYWQvODc4OA==.html - controlled-ev-profiles: https://zenodo.org/record/6579421/files/ramp-ev-consumption-profiles.csv.gz?download=1 + controlled-ev-profiles: https://zenodo.org/record/6579421/files/ramp-ev-{dataset}.csv.gz?download=1 uncontrolled-ev-profiles: https://sandbox.zenodo.org/records/45530/files/uncontrolled-charging-profiles.csv.gz?download=1 # TODO: convert into Zenodo repository gridded-temperature-data: https://zenodo.org/records/6557643/files/temperature.nc?download=1 gridded-10m-windspeed-data: https://zenodo.org/records/6557643/files/wind10m.nc?download=1 @@ -55,6 +55,7 @@ scaling-factors: # values are tuned for models with a few hours resolution and o power: 0.00001 # from MW(h) to 100 GW(h) area: 0.0001 # from km2 to 10,000 km2 monetary: 0.000000001 # from EUR to 1 billion EUR + transport: 0.01 # from Mio km to 100 Mio km capacity-factors: min: 0.001 max: 10 # 0.001 -> 10 leads to a numerical range of 1e5 (hourly resolution) @@ -167,7 +168,17 @@ parameters: coaches-and-buses: Motor coaches, buses and trolley buses passenger-cars: Passenger cars motorcycles: Powered 2-wheelers - uncontrolled-ev-charging-share: 0.5 + ev-battery-sizes: + heavy-duty-vehicles: 0.2 # average from [EUCAR_2019] + light-duty-vehicles: 0.1 # own assumption based on passenger cars from [EUCAR_2019] + motorcycles: 0.01 # own assumption + coaches-and-buses: 0.2 # own assumption based on HDVs from [EUCAR_2019] + passenger-cars: 0.08 # average from [EUCAR_2019] + uncontrolled-ev-charging-share: 1 + monthly-demand-bound-fraction: + min: 0.9 + max: 1.1 + equals: 1 entsoe-tyndp: scenario: National Trends grid: Reference diff --git a/config/schema.yaml b/config/schema.yaml index d1ef3103..443045a1 100644 --- a/config/schema.yaml +++ b/config/schema.yaml @@ -106,7 +106,7 @@ properties: controlled-ev-profiles: type: string pattern: ^(https?|http?):\/\/.+ - description: Web address of electric vehicle data. + description: Web address of electric vehicle data for controlled charging. uncontrolled-ev-profiles: type: string pattern: ^(https?|http?):\/\/.+ @@ -198,6 +198,9 @@ properties: monetary: type: number description: "Applied to all quantities of monetary cost (base: EUR)." + transport: + type: number + description: "Applied to all quantities of transport (base: Mio km)." parameters: type: object description: Parameter values of the models. @@ -368,9 +371,43 @@ properties: motorcycles: type: string description: JRC-IDEES name of motorcycles. + ev-battery-sizes: + type: object + description: EV battery size per vehicle type in MWh. + additionalProperties: false + properties: + light-duty-vehicles: + type: number + description: Light duty vehicles / trucks. + heavy-duty-vehicles: + type: number + description: Heavy duty vehicles / trucks. + coaches-and-buses: + type: number + description: Coaches and buses. + passenger-cars: + type: number + description: Passenger cars. + motorcycles: + type: number + description: Motorcylces. uncontrolled-ev-charging-share: type: number description: Share of uncontrolled charging. + monthly-demand-bound-fraction: + type: object + description: Charging flexibility parameter constraining the amount of monthly demand to be met. + additionalProperties: false + properties: + min: + type: number + description: Minimum monthly demand range. + max: + type: number + description: Maximum monthly demand range. + equals: + type: number + description: Equal monthly demand range. entsoe-tyndp: type: object description: Parameters to define scenario choice for data accessed from the ENTSO-E ten-year network development plan 2020. For more information, see https://2020.entsos-tyndp-scenarios.eu/ diff --git a/rules/transport.smk b/rules/transport.smk index dbf81f1c..b1b28672 100644 --- a/rules/transport.smk +++ b/rules/transport.smk @@ -6,7 +6,10 @@ rule download_transport_timeseries: params: url = config["data-sources"]["controlled-ev-profiles"] conda: "../envs/shell.yaml" - output: protected("data/automatic/ramp-ev-consumption-profiles.csv.gz") + output: + protected("data/automatic/ramp-ev-{dataset}.csv.gz") + wildcard_constraints: + dataset = "consumption-profiles|plugin-profiles" localrule: True shell: "curl -sSLo {output} {params.url}" @@ -38,16 +41,20 @@ rule annual_transport_demand: road_distance_historically_electrified = "build/data/transport/annual-road-transport-distance-demand-historic-electrification.csv", script: "../scripts/transport/annual_transport_demand.py" -rule create_controlled_road_transport_annual_demand: - message: "Create annual demand for controlled charging at {wildcards.resolution} resolution" +rule create_controlled_road_transport_annual_demand_and_installed_capacities: + message: "Create annual demand for controlled charging and corresponding charging potentials at {wildcards.resolution} resolution" input: annual_controlled_demand = "build/data/transport/annual-road-transport-distance-demand-controlled.csv", + ev_vehicle_number = "build/data/jrc-idees/transport/processed-road-vehicles.csv", + jrc_road_distance = "build/data/jrc-idees/transport/processed-road-distance.csv", locations = "build/data/{resolution}/units.csv", populations = "build/data/{resolution}/population.csv", params: first_year = config["scope"]["temporal"]["first-year"], final_year = config["scope"]["temporal"]["final-year"], power_scaling_factor = config["scaling-factors"]["power"], + transport_scaling_factor = config["scaling-factors"]["transport"], + battery_sizes = config["parameters"]["transport"]["ev-battery-sizes"], conversion_factors = config["parameters"]["transport"]["road-transport-conversion-factors"], countries = config["scope"]["spatial"]["countries"], country_neighbour_dict = config["data-pre-processing"]["fill-missing-values"]["ramp"], @@ -56,6 +63,23 @@ rule create_controlled_road_transport_annual_demand: main = "build/data/{resolution}/demand/electrified-transport.csv", script: "../scripts/transport/road_transport_controlled_charging.py" +rule create_controlled_ev_charging_parameters: + message: "Create timeseries parameters {wildcards.dataset_name} for controlled EV charging at {wildcards.resolution} resolution" + input: + ev_profiles = lambda wildcards: "data/automatic/ramp-ev-consumption-profiles.csv.gz" if "demand" in wildcards.dataset_name else f"data/automatic/ramp-ev-{wildcards.dataset_name}.csv.gz", + locations = "build/data/{resolution}/units.csv", + populations = "build/data/{resolution}/population.csv", + params: + demand_range = config["parameters"]["transport"]["monthly-demand-bound-fraction"], + first_year = config["scope"]["temporal"]["first-year"], + final_year = config["scope"]["temporal"]["final-year"], + country_neighbour_dict = config["data-pre-processing"]["fill-missing-values"]["ramp"], + countries = config["scope"]["spatial"]["countries"], + wildcard_constraints: + dataset_name = "demand-shape-equals|demand-shape-max|demand-shape-min|plugin-profiles" + conda: "../envs/default.yaml" + output: "build/models/{resolution}/timeseries/demand/{dataset_name}-ev.csv" + script: "../scripts/transport/road_transport_controlled_constraints.py" rule create_uncontrolled_road_transport_timeseries: message: "Create timeseries for road transport demand (uncontrolled charging)" diff --git a/scripts/transport/road_transport_controlled_charging.py b/scripts/transport/road_transport_controlled_charging.py index 083509da..e91a847a 100644 --- a/scripts/transport/road_transport_controlled_charging.py +++ b/scripts/transport/road_transport_controlled_charging.py @@ -45,7 +45,7 @@ def convert_annual_distance_to_electricity_demand( final_year: int, conversion_factors: dict[str, float], country_codes: list[str], -): +) -> pd.DataFrame: """ Convert annual distance driven demand to electricity demand for controlled charging accounting for conversion factors. @@ -65,6 +65,60 @@ def convert_annual_distance_to_electricity_demand( return -df_energy_demand +def extract_national_ev_charging_potentials( + path_to_ev_numbers: str, + transport_scaling_factor: float, + first_year: int, + final_year: int, + conversion_factors: dict[str, float], + battery_sizes: dict[str, float], + country_codes: list[str], +) -> pd.DataFrame: + # Extract number of EVs per vehicle type + df_ev_numbers = ( + pd.read_csv(path_to_ev_numbers, index_col=[0, 1, 2, 3, 4]) + .squeeze() + .droplevel(["vehicle_subtype", "section"]) + ) + assert not df_ev_numbers.isnull().values.any() + # Compute max. distance travelled per full battery for one EV [in Mio km / vehicle] + battery_size = pd.DataFrame.from_dict( + { + vehicle: battery_sizes[vehicle] / conversion_factors[vehicle] + for vehicle in battery_sizes + }, + orient="index", + columns=["value"], + ) + + # Compute available chargeable distance per vehicle type [in transport scaling unit km] + df_ev_chargeable_distance = ( + df_ev_numbers.align(battery_size, level="vehicle_type")[1] + .squeeze() + .mul(df_ev_numbers) + .groupby(level=["country_code", "year"]) + .sum() + .loc[country_codes] + .unstack("year") + .mul(transport_scaling_factor) + ) + + if final_year > 2015: + # ASSUME 2015 data is used for all years after 2015 + df_ev_chargeable_distance = df_ev_chargeable_distance.assign(**{ + str(year): df_ev_chargeable_distance[2015] + for year in range(2016, final_year + 1) + }) + + df_ev_chargeable_distance.columns = df_ev_chargeable_distance.columns.astype(int) + + return df_ev_chargeable_distance[range(first_year, final_year + 1)].T + + +def reshape_and_add_suffix(df, suffix): + return df.T.add_suffix(suffix) + + if __name__ == "__main__": resolution = snakemake.wildcards.resolution @@ -83,8 +137,12 @@ def convert_annual_distance_to_electricity_demand( .to_dict() ) populations = pd.read_csv(snakemake.input.populations, index_col=0) + battery_sizes = snakemake.params.battery_sizes + transport_scaling_factor = snakemake.params.transport_scaling_factor + path_to_ev_numbers = snakemake.input.ev_vehicle_number - df = convert_annual_distance_to_electricity_demand( + # Convert annual distance driven demand to electricity demand for controlled charging + df_demand = convert_annual_distance_to_electricity_demand( path_to_controlled_annual_demand, power_scaling_factor, first_year, @@ -93,14 +151,39 @@ def convert_annual_distance_to_electricity_demand( country_codes, ) - if resolution == "continental": - df = scale_to_continental_resolution(df) - elif resolution == "national": - df = scale_to_national_resolution(df) - elif resolution in ["regional", "ehighways"]: - df = scale_to_regional_resolution( - df, region_country_mapping=region_country_mapping, populations=populations - ) - else: - raise ValueError("Input resolution is not recognised") - df.T.to_csv(path_to_output, index_label=["id"]) + # Extract national EV charging potentials + df_charging_potentials = extract_national_ev_charging_potentials( + path_to_ev_numbers, + transport_scaling_factor, + first_year, + final_year, + conversion_factors, + battery_sizes, + country_codes, + ) + + # Add prefix for yaml template + parameters_evs = { + "_demand": df_demand, + "_charging": df_charging_potentials, + } + + # Rescale to desired resolution and add suffix + dfs = [] + for key, df in parameters_evs.items(): + if resolution == "continental": + df = scale_to_continental_resolution(df) + elif resolution == "national": + df = scale_to_national_resolution(df) + elif resolution in ["regional", "ehighways"]: + df = scale_to_regional_resolution( + df, + region_country_mapping=region_country_mapping, + populations=populations, + ) + else: + raise ValueError("Input resolution is not recognised") + dfs.append(reshape_and_add_suffix(df, key)) + + # Export to csv + pd.concat(dfs, axis=1).to_csv(path_to_output, index_label=["id"]) diff --git a/scripts/transport/road_transport_controlled_constraints.py b/scripts/transport/road_transport_controlled_constraints.py new file mode 100644 index 00000000..5761eff1 --- /dev/null +++ b/scripts/transport/road_transport_controlled_constraints.py @@ -0,0 +1,103 @@ +import pandas as pd +from eurocalliopelib import utils + + +def scale_to_resolution_and_create_file( + df, region_country_mapping, populations, resolution, output_path +): + if resolution == "national": + df = df + elif resolution == "continental": + df = df.sum(axis=1).to_frame("EUR") + elif resolution in ["regional", "ehighways"]: + df = scale_national_to_regional(df, region_country_mapping, populations) + else: + raise ValueError(f"Resolution {resolution} is not supported") + df.tz_localize(None).rename_axis("utc-timestamp").to_csv(output_path) + + +def scale_national_to_regional(df, region_country_mapping, populations): + df_population_share = ( + populations.loc[:, "population_sum"] + .reindex(region_country_mapping.keys()) + .groupby(by=region_country_mapping) + .transform(lambda df: df / df.sum()) + ) + + regional_df = ( + pd.DataFrame( + index=df.index, + data={ + id: df[country_code] + for id, country_code in region_country_mapping.items() + }, + ) + .mul(df_population_share) + .rename(columns=lambda col_name: col_name.replace(".", "-")) + ) + pd.testing.assert_series_equal(regional_df.sum(axis=1), df.sum(axis=1)) + return regional_df + + +def get_national_ev_profiles( + ev_profiles_path: str, + dataset_name: str, + demand_range: dict[str, int], + first_year: int, + final_year: int, + country_neighbour_dict: dict[str, list[str]], + country_codes: list[str], +) -> pd.DataFrame: + df_timeseries = ( + pd.read_csv(ev_profiles_path, index_col=[0, 1, 2], parse_dates=[0]) + .xs(slice(first_year, final_year), level="year") + .unstack("country_code") + .droplevel(level=0, axis="columns") + ) + if "demand" in dataset_name: + # Normalise demand and create min-max-equals timeseries + df = ( + df_timeseries.groupby(by=lambda idx: idx.year) + .transform(lambda x: x / x.sum()) + .mul(demand_range[dataset_name.split("-")[-1]]) + ) + elif "plugin" in dataset_name: + # plugin-profiles are already normalised + df = df_timeseries + return df.pipe(fill_empty_country, country_neighbour_dict).loc[:, country_codes] + + +def fill_empty_country(df, country_neighbour_dict): + for country, neighbours in country_neighbour_dict.items(): + assert country not in df.columns + df[country] = df[neighbours].mean(axis=1) + return df + + +if __name__ == "__main__": + region_country_mapping = ( + pd.read_csv(snakemake.input.locations, index_col=0) + .loc[:, "country_code"] + .to_dict() + ) + populations = pd.read_csv(snakemake.input.populations, index_col=0) + + df = get_national_ev_profiles( + ev_profiles_path=snakemake.input.ev_profiles, + dataset_name=snakemake.wildcards.dataset_name, + demand_range=snakemake.params.demand_range, + first_year=snakemake.params.first_year, + final_year=snakemake.params.final_year, + country_neighbour_dict=snakemake.params.country_neighbour_dict, + country_codes=utils.convert_valid_countries( + snakemake.params.countries + ).values(), + ) + + scale_to_resolution_and_create_file( + df=df, + region_country_mapping=region_country_mapping, + populations=populations, + resolution=snakemake.wildcards.resolution, + output_path=snakemake.output[0], + ) diff --git a/templates/models/techs/demand/electrified-heat.yaml b/templates/models/techs/demand/electrified-heat.yaml index 4f0ad4f7..40467372 100644 --- a/templates/models/techs/demand/electrified-heat.yaml +++ b/templates/models/techs/demand/electrified-heat.yaml @@ -14,7 +14,7 @@ techs: carrier: electricity constraints: resource: file=demand/heat-demand-historic-electrification.csv - resource_min_use: 1 + force_resource: true overrides: keep-historic-electricity-demand-from-heat: diff --git a/templates/models/techs/demand/electrified-transport.yaml b/templates/models/techs/demand/electrified-transport.yaml index bfba831b..1bd3e0bf 100644 --- a/templates/models/techs/demand/electrified-transport.yaml +++ b/templates/models/techs/demand/electrified-transport.yaml @@ -25,6 +25,17 @@ techs: force_resource: false resource: -.inf + road_transport_controlled_dummy: + exists: false + essentials: + name: 'Dummy tech for controlled road transport demand -- required for max potential charging and demand shape' + parent: conversion + carrier_in: electricity + carrier_out: electricity + constraints: + energy_eff: 1 + energy_cap_max_time_varying: file=demand/plugin-profiles-ev.csv + overrides: keep-historic-electricity-demand-from-road-transport: # TODO: possibly remove this override as there may be no use-cases for it. @@ -33,15 +44,46 @@ overrides: {% endfor %} {% for year in locations.columns %} - {{ year }}_transport_controlled_electrified_demand: + {% if "demand" in year %} + {{ year }}_transport_controlled_electrified: group_constraints: {% for location in locations.index %} - {{ location }}_annual_controlled_electricity_demand: + {{ location }}_annual_controlled_electricity: locs: [{{ location }}] techs: [demand_road_transport_electrified_controlled] carrier_con_equals: electricity: {{ locations.loc[location, year] }} # {{ (1 / scaling_factors.power) | unit("MWh") }} {% endfor %} + {% endif %} + {% endfor %} + + monthly_transport_demand_range: + techs: + demand_road_transport_electrified_controlled: + constraints: + demand_shape_per_month_min_time_varying: file=demand/demand-shape-min-ev.csv + demand_shape_per_month_max_time_varying: file=demand/demand-shape-max-ev.csv + + monthly_transport_demand_equality: + techs: + demand_road_transport_electrified_controlled: + constraints: + demand_shape_per_month_equals_time_varying: file=demand/demand-shape-equals-ev.csv + + {% for year in locations.columns %} + {% if "charging" in year %} + {{ year }}_max_ev_potential: + techs: + road_transport_controlled_dummy.exists: true + locations: + {% for location in locations.index %} + {{ location }}: + techs: + road_transport_controlled_dummy: + constraints: + energy_cap_max: {{ locations.loc[location, year] }} # {{ (1 / scaling_factors.transport) | unit("Mio km") }} + {% endfor %} + {% endif %} {% endfor %} locations: