diff --git a/config/config.default.yaml b/config/config.default.yaml index cd940e1a55..3e089040d6 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -1190,6 +1190,7 @@ solving: EQ: false BAU: false SAFE: false + co2_budget_national: {} solver: name: gurobi options: "gurobi-default" diff --git a/config/schema.default.json b/config/schema.default.json index bed19a77ee..7b1e1c9c63 100644 --- a/config/schema.default.json +++ b/config/schema.default.json @@ -5476,6 +5476,16 @@ "default": false, "description": "Add a capacity reserve margin of a certain fraction above the peak demand to which renewable generators and storage do *not* contribute. Ignores network.", "type": "boolean" + }, + "co2_budget_national": { + "additionalProperties": { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + "description": "Per-country CO2 budget constraints for myopic foresight, keyed by two-letter country code and then by planning horizon year. Values are the allowed emissions as a fraction of the country's 1990 emissions (e.g. `0.0` for net-zero, negative for net-negative). Emissions are balanced over all links of a country connecting to the `co2 atmosphere` bus, with aviation scaled by the domestic share. Leave empty to disable.", + "type": "object" } } }, @@ -5999,6 +6009,16 @@ "default": false, "description": "Add a capacity reserve margin of a certain fraction above the peak demand to which renewable generators and storage do *not* contribute. Ignores network.", "type": "boolean" + }, + "co2_budget_national": { + "additionalProperties": { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + "description": "Per-country CO2 budget constraints for myopic foresight, keyed by two-letter country code and then by planning horizon year. Values are the allowed emissions as a fraction of the country's 1990 emissions (e.g. `0.0` for net-zero, negative for net-negative). Emissions are balanced over all links of a country connecting to the `co2 atmosphere` bus, with aviation scaled by the domestic share. Leave empty to disable.", + "type": "object" } } }, @@ -12540,6 +12560,16 @@ "default": false, "description": "Add a capacity reserve margin of a certain fraction above the peak demand to which renewable generators and storage do *not* contribute. Ignores network.", "type": "boolean" + }, + "co2_budget_national": { + "additionalProperties": { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + "description": "Per-country CO2 budget constraints for myopic foresight, keyed by two-letter country code and then by planning horizon year. Values are the allowed emissions as a fraction of the country's 1990 emissions (e.g. `0.0` for net-zero, negative for net-negative). Emissions are balanced over all links of a country connecting to the `co2 atmosphere` bus, with aviation scaled by the domestic share. Leave empty to disable.", + "type": "object" } } }, diff --git a/doc/foresight.rst b/doc/foresight.rst index 6a49c63373..6382d4ca6a 100644 --- a/doc/foresight.rst +++ b/doc/foresight.rst @@ -194,6 +194,56 @@ transformations required in Europe to achieve different climate goals (2022) `__. +.. _national_co2_budgets: + +National CO2 budgets +-------------------- + +In addition to the system-wide carbon budget, individual countries can be given +their own CO2 budget to model differentiated, country-specific climate targets +(for example, Austria's path to climate neutrality by 2040 following its +*Klimaschutzgesetz* targets). National budgets are available in myopic foresight +and are configured under ``solving: constraints: co2_budget_national`` as a +fraction of each country's 1990 emissions, keyed by country code and planning +horizon: + +.. code:: yaml + + solving: + constraints: + co2_budget_national: + AT: + 2020: 0.67 # 67% of 1990 emissions + 2030: 0.34 + 2040: 0.00 # net-zero + 2050: -0.05 # net-negative + +Leave the mapping empty (the default) to disable the feature. The absolute +1990 reference is taken from ``co2_totals.csv`` for the emission sectors enabled +in the ``sector`` configuration, scaled to the number of years represented by +the planning horizon. + +For every listed country and planning horizon a constraint is added that +balances all emissions at the ``co2 atmosphere`` bus: + +1. all links of the country connecting to the ``co2 atmosphere`` bus are + identified, across all of their bus ports; +2. emissions (positive and negative) are accounted for in the region where the + technology causes them, so emissions in one region of a country can be + compensated by negative emissions (CCS, DAC, CCU) in another region of the + *same* country; +3. aviation emissions are scaled by the domestic-to-total aviation ratio + (from ``energy_totals.csv``) so that international aviation is excluded; +4. the resulting sum is constrained to be below the national budget: + :math:`\sum \text{emissions} \le \text{budget}_{ct}`. + +Unlike the PyPSA-DE implementation this feature is derived from, no distinction +is made between synthetic and fossil fuels: all emissions are booked where +combustion occurs, and a country cannot use synthetic-fuel production to offset +emissions in another country. The constraint can be combined with the global +``co2limit``/carbon budget, which continues to cap system-wide emissions. + + General myopic code structure --------------------------------- diff --git a/doc/release_notes.rst b/doc/release_notes.rst index d2b877bc60..3ba288950e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -9,6 +9,8 @@ Release Notes .. Upcoming Release .. ================= +* feat: Add optional per-country CO2 budget constraints for myopic foresight, configurable via ``solving: constraints: co2_budget_national`` as a fraction of each country's 1990 emissions per planning horizon. Emissions are balanced at the ``co2 atmosphere`` bus per country, with aviation scaled by the domestic share. See :ref:`national_co2_budgets`. + * Fix: ``atlite.plot_availability_matrix`` config option for :mod:`determine_availability_matrix` and :mod:`determine_availability_matrix_MD_UA` scripts, changed their output and behaviour to align consistently (https://github.com/PyPSA/pypsa-eur/pull/2173). * Fix: Activate losses for `H2 pipeline retrofitted` links by default, to ensure consistency with `H2 pipeline` links. diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index edc8c59a94..17221b3053 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -109,6 +109,8 @@ rule solve_sector_network_myopic: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_brownfield.nc" ), + co2_totals=resources("co2_totals.csv"), + energy_totals=resources("energy_totals.csv"), output: network=RESULTS + "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc", @@ -144,6 +146,7 @@ rule solve_sector_network_myopic: co2_sequestration_potential=config_provider( "sector", "co2_sequestration_potential", default=200 ), + energy_totals_year=config_provider("energy", "energy_totals_year"), custom_extra_functionality=input_custom_extra_functionality, message: "Solving sector-coupled network with myopic foresight for {wildcards.clusters} clusters, {wildcards.planning_horizons} planning horizons, {wildcards.opts} electric options and {wildcards.sector_opts} sector options" diff --git a/scripts/lib/validation/config/solving.py b/scripts/lib/validation/config/solving.py index 878e381a3a..743ed860e5 100644 --- a/scripts/lib/validation/config/solving.py +++ b/scripts/lib/validation/config/solving.py @@ -245,6 +245,10 @@ class _ConstraintsConfig(BaseModel): False, description="Add a capacity reserve margin of a certain fraction above the peak demand to which renewable generators and storage do *not* contribute. Ignores network.", ) + co2_budget_national: dict[str, dict[int, float]] = Field( + default_factory=dict, + description="Per-country CO2 budget constraints for myopic foresight, keyed by two-letter country code and then by planning horizon year. Values are the allowed emissions as a fraction of the country's 1990 emissions (e.g. `0.0` for net-zero, negative for net-negative). Emissions are balanced over all links of a country connecting to the `co2 atmosphere` bus, with aviation scaled by the domestic share. Leave empty to disable.", + ) class _SolverConfig(BaseModel): diff --git a/scripts/solve_network.py b/scripts/solve_network.py index 4b6429b47c..cb06058c56 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1220,6 +1220,147 @@ def add_co2_atmosphere_constraint(n, snapshots): n.model.add_constraints(lhs <= rhs, name=f"GlobalConstraint-{name}") +def add_national_co2_budget_constraints( + n: pypsa.Network, + planning_horizons: str, + snakemake, +) -> None: + """ + Add per-country CO2 budget constraints based on a balance at the + ``co2 atmosphere`` bus. + + Budgets are configured per country and planning horizon under + ``solving: constraints: co2_budget_national`` as a fraction of the + country's 1990 emissions. Emissions (positive and negative) are + balanced over all links of a country that connect to the + ``co2 atmosphere`` bus, so that emissions are accounted for in the + region where they occur. Whether fossil or synthetic fuels are burned + makes no difference; all emissions are booked where combustion occurs. + Aviation kerosene emissions are scaled by the domestic-to-total + aviation ratio to exclude international aviation from the budget. + """ + from scripts.prepare_sector_network import determine_emission_sectors + + national_co2_budgets = snakemake.config["solving"]["constraints"][ + "co2_budget_national" + ] + investment_year = int(planning_horizons) + + logger.info(f"Adding national CO2 budgets for year {investment_year}") + + nyears = n.snapshot_weightings.generators.sum() / 8760 + MtCO2_to_tCO2 = 1e6 + + co2_totals = pd.read_csv(snakemake.input.co2_totals, index_col=0).mul(MtCO2_to_tCO2) + sectors = determine_emission_sectors(n.config["sector"]) + co2_sector = co2_totals[sectors].sum(axis=1) * nyears + + energy_totals = pd.read_csv(snakemake.input.energy_totals, index_col=[0, 1]) + energy_year = int(snakemake.params.energy_totals_year) + + weightings = n.snapshot_weightings.generators + links = n.links + + for ct, yearly_percent in national_co2_budgets.items(): + if investment_year in yearly_percent: + continue + + percent = yearly_percent[investment_year] + budget = co2_sector[ct] * percent + logger.info( + f"Limiting emissions in country {ct} to " + f"{percent:.1%} of 1990 levels, " + f"i.e. {budget:,.2f} tCO2/a" + ) + + lhs = [] + link_ports = links.filter(like="bus").columns.str[3:] + for port in link_ports: + # aviation is excluded here to scale it by the domestic share below + idx = links.query( + f"name.str.startswith('{ct}') " + f"& bus{port} == 'co2 atmosphere' " + f"& carrier != 'kerosene for aviation'" + ).index + + if idx.empty: + continue + + logger.info( + f"For {ct} adding the following link carriers on port {port} " + f"to the CO2 constraint: {sorted(links.loc[idx, 'carrier'].unique())}" + ) + + if port == "0": + efficiency = -1.0 + elif port == "1": + efficiency = links.loc[idx, "efficiency"] + else: + efficiency = links.loc[idx, f"efficiency{port}"] + + port_emissions = ( + n.model["Link-p"].loc[:, idx].mul(efficiency).mul(weightings).sum() + ) + lhs.append(port_emissions) + + # Scale aviation emissions by the domestic share to exclude + # international aviation from the national budget. + aviation_domestic = energy_totals.loc[ + (ct, energy_year), "total domestic aviation" + ] + aviation_international = energy_totals.loc[ + (ct, energy_year), "total international aviation" + ] + aviation_total = aviation_domestic + aviation_international + domestic_aviation_factor = ( + aviation_domestic / aviation_total if aviation_total else 0.0 + ) + aviation_links = links.query( + f"name.str.startswith('{ct}') & carrier == 'kerosene for aviation'" + ) + if not aviation_links.empty: + aviation_emissions = ( + n.model["Link-p"] + .loc[:, aviation_links.index] + # 'co2 atmosphere' is assumed to be at bus2 for aviation links + .mul(aviation_links["efficiency2"]) + .mul(weightings) + .sum() + .mul(domestic_aviation_factor) + ) + lhs.append(aviation_emissions) + logger.info( + f"Adding domestic aviation emissions for {ct} with a " + f"factor of {domestic_aviation_factor:.2f}" + ) + + if not lhs: + logger.warning( + f"No links connecting to 'co2 atmosphere' found for {ct}; " + "skipping its national CO2 budget constraint." + ) + continue + + cname = f"co2_limit-{ct}" + + n.model.add_constraints(sum(lhs) <= budget, name=f"GlobalConstraint-{cname}") + + if cname in n.global_constraints.index: + logger.warning( + f"Global constraint {cname} already exists. Dropping and re-adding it." + ) + n.global_constraints.drop(cname, inplace=True) + + n.add( + "GlobalConstraint", + cname, + constant=budget, + sense="<=", + type="", + carrier_attribute="", + ) + + def extra_functionality( n: pypsa.Network, snapshots: pd.DatetimeIndex, planning_horizons: str | None = None ) -> None: @@ -1283,6 +1424,8 @@ def extra_functionality( add_retrofit_gas_boiler_constraint(n, snapshots) else: add_co2_atmosphere_constraint(n, snapshots) + if constraints.get("co2_budget_national") and planning_horizons is not None: + add_national_co2_budget_constraints(n, planning_horizons, snakemake) # pylint: disable=E0601 if config["sector"]["enhanced_geothermal"]["enable"]: add_flexible_egs_constraint(n)