diff --git a/CHANGELOG.md b/CHANGELOG.md index 221fccd6..f085fa48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added (models) * **ADD** fully-electrified heat demand (#284). -* **ADD** fully-electrified road transportation (#270), (#271). +* **ADD** fully-electrified road transportation (#270), (#271). A parameter allows to define the share of uncontrolled (timeseries) vs controlled charging (optimised) by the solver (PR #338). * **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 8944b687..ec6306e5 100644 --- a/Snakefile +++ b/Snakefile @@ -32,6 +32,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 ALL_CF_TECHNOLOGIES = [ "wind-onshore", "wind-offshore", "open-field-pv", @@ -103,7 +104,7 @@ rule all_tests: rule dummy_tech_locations_template: # needed to provide `techs_and_locations_template` with a locational CSV linked to each technology that has no location-specific data to define. - message: "Create empty {wildcards.resolution} location-specific data file for the {wildcards.tech_group} tech `{wildcards.tech}`." + message: "Create empty {wildcards.resolution} location-specific data file for the {wildcards.tech_group} tech `{wildcards.tech}`." # Update ruleorder at the top of the file if you instead want the techs_and_locations_template rule to be used to generate a file input: rules.locations_template.output.csv output: "build/data/{resolution}/{tech_group}/{tech}.csv" conda: "envs/shell.yaml" @@ -180,8 +181,8 @@ rule model_template: ), demand_timeseries_data = ( "build/models/{resolution}/timeseries/demand/electricity.csv", - "build/models/{resolution}/timeseries/demand/electrified-road-transport.csv", - "build/models/{resolution}/timeseries/demand/road-transport-historic-electrification.csv", + "build/models/{resolution}/timeseries/demand/uncontrolled-electrified-road-transport.csv", + "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", ), diff --git a/config/default.yaml b/config/default.yaml index 56d09b07..1dc50ca3 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -162,6 +162,7 @@ parameters: coaches-and-buses: Motor coaches, buses and trolley buses passenger-cars: Passenger cars motorcycles: Powered 2-wheelers + uncontrolled-ev-charging-share: 0.5 entsoe-tyndp: scenario: National Trends grid: Reference diff --git a/config/schema.yaml b/config/schema.yaml index 97d1badf..d8e7c175 100644 --- a/config/schema.yaml +++ b/config/schema.yaml @@ -348,6 +348,9 @@ properties: motorcycles: type: string description: JRC-IDEES name of motorcycles. + uncontrolled-ev-charging-share: + type: number + description: Share of uncontrolled charging. 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/docs/model/customisation.md b/docs/model/customisation.md index 7be72396..c8a4243d 100644 --- a/docs/model/customisation.md +++ b/docs/model/customisation.md @@ -42,14 +42,18 @@ Here, we describe each module in terms of the technologies they contain (`callio === "Technologies" - **demand_road_transport_electrified**: Electrified road transport demand + **demand_road_transport_electrified_uncontrolled**: Share of electrified road transport demand which is uncontrolled. - **demand_road_transport_historic_electrified**: Removes historically electrified road transport demand to avoid double counting + **demand_road_transport_historic_electrified_uncontrolled**: Removes historically electrified road transport demand to avoid double counting. It is assumed uncontrolled. + + **demand_road_transport_electrified_controlled**: Share of electrified road transport demand whose charging is optimised by the solver. === "Overrides" **keep-historic-electricity-demand-from-road-transport**: Keep historically electrified road transport demand. Historically electrified road transport demand is deleted by default, as it is already considered in historic electricity demand and would thus be counted twice. Using this override together with Euro-Calliope's default electricity demand is not advised. + **(year)_transport_controlled_electrified_demand**: Total electrified road transport demand whose charging is optimised by the solver. + ??? note "demand/electrified-heat.yaml" === "Technologies" @@ -58,6 +62,7 @@ Here, we describe each module in terms of the technologies they contain (`callio **demand_heat_historic_electrified**: Removes historically electrified heat demand to avoid double counting + === "Overrides" **keep-historic-electricity-demand-from-heat**: Keep historically electrified heat demand. Historically electrified heat demand is deleted by default, as it is already considered in historic electricity demand and would thus be counted twice. Using this override together with Euro-Calliope's default electricity demand is not advised. diff --git a/rules/transport.smk b/rules/transport.smk index a3ecb5a3..4f247bf8 100644 --- a/rules/transport.smk +++ b/rules/transport.smk @@ -2,6 +2,7 @@ rule download_transport_timeseries: + # TODO have correct timeseries data once RAMP has generated the new charging profile and it's been put on Zenodo message: "Get EV data from RAMP" params: url = config["data-sources"]["ev-data"] @@ -35,18 +36,38 @@ rule annual_transport_demand: jrc_road_distance = "build/data/jrc-idees/transport/processed-road-distance.csv", params: fill_missing_values = config["data-pre-processing"]["fill-missing-values"]["jrc-idees"], - efficiency_quantile = config["parameters"]["transport"]["future-vehicle-efficiency-percentile"] + efficiency_quantile = config["parameters"]["transport"]["future-vehicle-efficiency-percentile"], + uncontrolled_charging_share = config["parameters"]["transport"]["uncontrolled-ev-charging-share"], conda: "../envs/default.yaml" output: - distance = "build/data/transport/annual-road-transport-distance-demand.csv", - distance_historic_electrification = "build/data/transport/annual-road-transport-historic-electrification.csv", + road_distance_controlled = "build/data/transport/annual-road-transport-distance-demand-controlled.csv", + road_distance_uncontrolled = "build/data/transport/annual-road-transport-distance-demand-uncontrolled.csv", + 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" + input: + annual_controlled_demand = "build/data/transport/annual-road-transport-distance-demand-controlled.csv", + locations = "build/data/regional/units.csv", + populations = "build/data/regional/population.csv", + params: + first_year = config["scope"]["temporal"]["first-year"], + final_year = config["scope"]["temporal"]["final-year"], + power_scaling_factor = config["scaling-factors"]["power"], + 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"], + conda: "../envs/default.yaml" + output: + main = "build/data/{resolution}/demand/electrified-transport.csv", + script: "../scripts/transport/road_transport_controlled_charging.py" + -rule create_road_transport_timeseries: - message: "Create timeseries for road transport demand" +rule create_uncontrolled_road_transport_timeseries: + message: "Create timeseries for road transport demand (uncontrolled charging)" input: - annual_data = "build/data/transport/annual-road-transport-distance-demand.csv", + annual_data = "build/data/transport/annual-road-transport-distance-demand-uncontrolled.csv", timeseries = "data/automatic/ramp-ev-consumption-profiles.csv.gz" params: first_year = config["scope"]["temporal"]["first-year"], @@ -60,14 +81,14 @@ rule create_road_transport_timeseries: wildcard_constraints: vehicle_type = "light-duty-vehicles|heavy-duty-vehicles|coaches-and-buses|passenger-cars|motorcycles" output: - main = "build/data/transport/timeseries/timeseries-{vehicle_type}.csv", + main = "build/data/transport/timeseries/timeseries-uncontrolled-{vehicle_type}.csv", script: "../scripts/transport/road_transport_timeseries.py" -use rule create_road_transport_timeseries as create_road_transport_timeseries_historic_electrification with: - message: "Create timeseries for historic electrified road transport demand" +use rule create_uncontrolled_road_transport_timeseries as create_uncontrolled_road_transport_timeseries_historic_electrification with: + message: "Create timeseries for historic electrified road transport demand (uncontrolled charging)" input: - annual_data = "build/data/transport/annual-road-transport-historic-electrification.csv", + annual_data = "build/data/transport/annual-road-transport-distance-demand-historic-electrification.csv", timeseries = "data/automatic/ramp-ev-consumption-profiles.csv.gz", params: first_year = config["scope"]["temporal"]["first-year"], @@ -78,34 +99,32 @@ use rule create_road_transport_timeseries as create_road_transport_timeseries_hi countries = config["scope"]["spatial"]["countries"], country_neighbour_dict = config["data-pre-processing"]["fill-missing-values"]["ramp"], output: - "build/data/transport/timeseries/timeseries-{vehicle_type}-historic-electrification.csv" + "build/data/transport/timeseries/timeseries-uncontrolled-{vehicle_type}-historic-electrification.csv" rule aggregate_timeseries: # TODO consider merge with other rules, as this is tiny atm - message: "Aggregates timeseries for {wildcards.resolution} electrified road transport transport" + message: "Aggregates uncontrolled charging timeseries for {wildcards.resolution} electrified road transport transport" input: - time_series = ( - "build/data/transport/timeseries/timeseries-light-duty-vehicles.csv", - "build/data/transport/timeseries/timeseries-heavy-duty-vehicles.csv", - "build/data/transport/timeseries/timeseries-coaches-and-buses.csv", - "build/data/transport/timeseries/timeseries-passenger-cars.csv", - "build/data/transport/timeseries/timeseries-motorcycles.csv"), + time_series = [ + f'build/data/transport/timeseries/timeseries-uncontrolled-{vehicle_type}.csv' + for vehicle_type in config["parameters"]["transport"]["road-transport-conversion-factors"].keys() + ], locations = "build/data/regional/units.csv", populations = "build/data/regional/population.csv" conda: "../envs/default.yaml" output: - "build/models/{resolution}/timeseries/demand/electrified-road-transport.csv", + "build/models/{resolution}/timeseries/demand/uncontrolled-electrified-road-transport.csv", script: "../scripts/transport/aggregate_timeseries.py" use rule aggregate_timeseries as aggregate_timeseries_historic_electrified with: - message: "Aggregates timeseries for {wildcards.resolution} historically electrified road transport" + message: "Aggregates uncontrolled charging timeseries for {wildcards.resolution} historically electrified road transport" input: time_series = ( - "build/data/transport/timeseries/timeseries-light-duty-vehicles-historic-electrification.csv", - "build/data/transport/timeseries/timeseries-coaches-and-buses-historic-electrification.csv", - "build/data/transport/timeseries/timeseries-passenger-cars-historic-electrification.csv"), + "build/data/transport/timeseries/timeseries-uncontrolled-light-duty-vehicles-historic-electrification.csv", + "build/data/transport/timeseries/timeseries-uncontrolled-coaches-and-buses-historic-electrification.csv", + "build/data/transport/timeseries/timeseries-uncontrolled-passenger-cars-historic-electrification.csv"), locations = "build/data/regional/units.csv", populations = "build/data/regional/population.csv" output: - "build/models/{resolution}/timeseries/demand/road-transport-historic-electrification.csv" + "build/models/{resolution}/timeseries/demand/uncontrolled-road-transport-historic-electrification.csv" diff --git a/scripts/transport/annual_transport_demand.py b/scripts/transport/annual_transport_demand.py index b95247a9..8e1cbb8d 100644 --- a/scripts/transport/annual_transport_demand.py +++ b/scripts/transport/annual_transport_demand.py @@ -229,8 +229,23 @@ def fill_missing_countries_and_years( .xs("electricity") ) - # Create CSV Files for calculated data - total_road_distance.rename("value").to_csv(snakemake.output.distance) - total_historically_electrified_distance.rename("value").to_csv( - snakemake.output.distance_historic_electrification + # Separate uncontrolled and controlled charging demands and create csv files + uncontrolled_share = snakemake.params.uncontrolled_charging_share + + road_distance_controlled = ( + total_road_distance.rename("value") + .mul(1 - uncontrolled_share) + .to_csv(snakemake.output.road_distance_controlled) + ) + road_distance_uncontrolled = ( + total_road_distance.rename("value") + .mul(uncontrolled_share) + .sub(total_historically_electrified_distance.rename("value"), fill_value=0) + .to_csv(snakemake.output.road_distance_uncontrolled) + ) + # ASSUME historically electrified road consumption is all uncontrolled + road_distance_historically_electrified = ( + total_historically_electrified_distance.rename("value").to_csv( + snakemake.output.road_distance_historically_electrified + ) ) diff --git a/scripts/transport/road_transport_controlled_charging.py b/scripts/transport/road_transport_controlled_charging.py new file mode 100644 index 00000000..ccb9fe91 --- /dev/null +++ b/scripts/transport/road_transport_controlled_charging.py @@ -0,0 +1,106 @@ +import pandas as pd +import pycountry + + +def scale_to_regional_resolution(df, region_country_mapping, populations): + """ + Create regional electricity demand for controlled charging. + ASSUME all road transport is subnationally distributed in proportion to population. + """ + 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 scale_to_national_resolution(df): + df.columns.name = None + return df + + +def scale_to_continental_resolution(df): + return df.sum(axis=1).to_frame("EUR") + + +def convert_annual_distance_to_electricity_demand( + path_to_controlled_annual_demand: str, + power_scaling_factor: float, + first_year: int, + final_year: int, + conversion_factors: dict[str, float], + country_codes: list[str], +): + """ + Convert annual distance driven demand to electricity demand for + controlled charging accounting for conversion factors. + """ + df_energy_demand = ( + pd.read_csv(path_to_controlled_annual_demand, index_col=[1, 2]) + .xs(slice(first_year, final_year), level="year", drop_level=False) + .assign(value=lambda x: x["value"] * x["vehicle_type"].map(conversion_factors)) + .groupby(["country_code", "year"]) + .sum() + .loc[country_codes] + .mul(power_scaling_factor) + .squeeze() + .unstack("country_code") + ) + + return -df_energy_demand + + +if __name__ == "__main__": + resolution = snakemake.wildcards.resolution + + path_to_controlled_annual_demand = snakemake.input.annual_controlled_demand + power_scaling_factor = snakemake.params.power_scaling_factor + first_year = snakemake.params.first_year + final_year = snakemake.params.final_year + conversion_factors = snakemake.params.conversion_factors + path_to_output = snakemake.output[0] + country_codes = ( + [pycountry.countries.lookup(c).alpha_3 for c in snakemake.params.countries], + ) + 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 = convert_annual_distance_to_electricity_demand( + path_to_controlled_annual_demand, + power_scaling_factor, + first_year, + final_year, + conversion_factors, + country_codes, + ) + + if resolution == "continental": + df = scale_to_continental_resolution(df) + elif resolution == "national": + df = scale_to_national_resolution(df) + elif resolution == "regional": + 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"]) diff --git a/templates/models/techs/demand/electrified-transport.yaml b/templates/models/techs/demand/electrified-transport.yaml index aeffe7d0..bfba831b 100644 --- a/templates/models/techs/demand/electrified-transport.yaml +++ b/templates/models/techs/demand/electrified-transport.yaml @@ -1,29 +1,53 @@ techs: - demand_road_transport_electrified: + demand_road_transport_electrified_uncontrolled: essentials: - name: 'Electrified road transport demand' + name: 'Uncontrolled electrified road transport demand -- follows a timeseries' parent: demand carrier: electricity constraints: - resource: file=demand/electrified-road-transport.csv + resource: file=demand/uncontrolled-electrified-road-transport.csv - demand_road_transport_historic_electrified: + demand_road_transport_historic_electrified_uncontrolled: essentials: - name: 'Removes historic electrified road transport demand' + name: 'Removes historic electrified road transport demand from ENTSOE-derived historical electricity demand profile -- assumed uncontrolled' parent: supply carrier: electricity constraints: - resource: file=demand/road-transport-historic-electrification.csv - resource_min_use: 1 + resource: file=demand/uncontrolled-road-transport-historic-electrification.csv + force_resource: true + + demand_road_transport_electrified_controlled: + essentials: + name: 'Controlled electrified road transport demand' + parent: demand + carrier: electricity + constraints: + force_resource: false + resource: -.inf overrides: keep-historic-electricity-demand-from-road-transport: + # TODO: possibly remove this override as there may be no use-cases for it. {% for id, location in locations.iterrows() %} - {{ id }}.techs.demand_road_transport_historic_electrified.exists: False + {{ id }}.techs.demand_road_transport_historic_electrified_uncontrolled.exists: false {% endfor %} + {% for year in locations.columns %} + {{ year }}_transport_controlled_electrified_demand: + group_constraints: + {% for location in locations.index %} + {{ location }}_annual_controlled_electricity_demand: + locs: [{{ location }}] + techs: [demand_road_transport_electrified_controlled] + carrier_con_equals: + electricity: {{ locations.loc[location, year] }} # {{ (1 / scaling_factors.power) | unit("MWh") }} + {% endfor %} + {% endfor %} + locations: {% for id, location in locations.iterrows() %} - {{ id }}.techs.demand_road_transport_electrified: - {{ id }}.techs.demand_road_transport_historic_electrified: + {{ id }}.techs: + demand_road_transport_electrified_uncontrolled: + demand_road_transport_historic_electrified_uncontrolled: + demand_road_transport_electrified_controlled: {% endfor %}