Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,7 @@ solving:
EQ: false
BAU: false
SAFE: false
co2_budget_national: {}
solver:
name: gurobi
options: "gurobi-default"
Expand Down
30 changes: 30 additions & 0 deletions config/schema.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
50 changes: 50 additions & 0 deletions doc/foresight.rst
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,56 @@ transformations required in Europe to achieve different climate goals (2022)
<https://doi.org/10.1016/j.joule.2022.04.016>`__.


.. _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
---------------------------------

Expand Down
2 changes: 2 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions rules/solve_myopic.smk
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions scripts/lib/validation/config/solving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
143 changes: 143 additions & 0 deletions scripts/solve_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading