Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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"][
Comment thread
pworschischek-aggmag marked this conversation as resolved.
"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:
Comment thread
pworschischek-aggmag marked this conversation as resolved.
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:
Comment thread
pworschischek-aggmag marked this conversation as resolved.
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
Comment thread
pworschischek-aggmag marked this conversation as resolved.
)
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"])
Comment thread
pworschischek-aggmag marked this conversation as resolved.
.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