Skip to content
Open
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
8 changes: 8 additions & 0 deletions config/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ renewable:
eia_correct_by_capacity: false
eia_approximate_missing: false

# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#perennials
perennials:
sequestration_co2: 2

# docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#conventional
conventional:
unit_commitment: false
Expand Down Expand Up @@ -837,6 +841,7 @@ sector:
methanation: true
coal_cc: false
dac: true
perennials: false
co2_vent: false
heat_vent:
urban central: true
Expand Down Expand Up @@ -1325,6 +1330,9 @@ data:
instrat_co2_prices:
source: primary
version: latest
co2_removal_data:
source: primary
version: latest
co2stop:
source: archive
version: latest
Expand Down
1 change: 1 addition & 0 deletions config/plotting.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -741,3 +741,4 @@ plotting:
import NH3: '#e2ed74'
import oil: '#93eda2'
import methanol: '#87d0e6'
co2 perennials: '#008ffc'
70 changes: 70 additions & 0 deletions config/schema.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,26 @@
}
}
},
"co2_removal_data": {
"description": "Configuration for a single data source.",
"properties": {
"source": {
"default": "archive",
"description": "Source of the data. 'archive' retrieves pre-built data, 'primary' retrieves from primary source.",
"enum": [
"archive",
"primary",
"build"
],
"type": "string"
},
"version": {
"default": "latest",
"description": "Version of the data to use. Uses the specific 'version' for the selected 'source' or the dataset tagged 'latest' for this source.",
"type": "string"
}
}
},
"co2stop": {
"description": "Configuration for a single data source.",
"properties": {
Expand Down Expand Up @@ -2925,6 +2945,16 @@
}
}
},
"PerennialsConfig": {
"description": "Configuration for `perennials` settings.",
"properties": {
"sequestration_co2": {
"default": 2,
"description": "Tonnes of CO2 equivalent sequestered per hectare per year when 1st-generation biofuel cropland is converted to perennial grasses.",
"type": "number"
}
}
},
"PypsaEurConfig": {
"description": "Configuration for `pypsa_eur` settings.",
"properties": {
Expand Down Expand Up @@ -4647,6 +4677,11 @@
"description": "Add option for Direct Air Capture (DAC).",
"type": "boolean"
},
"perennials": {
"default": false,
"description": "Add option for perennialisation (converting 1st-generation biofuel cropland to perennial grasses) as a carbon dioxide removal (CDR) technology.",
"type": "boolean"
},
"co2_vent": {
"default": false,
"description": "Add option for vent out CO2 from storages to the atmosphere.",
Expand Down Expand Up @@ -10091,6 +10126,16 @@
}
}
},
"perennials": {
"description": "Configuration for `perennials` settings.",
"properties": {
"sequestration_co2": {
"default": 2,
"description": "Tonnes of CO2 equivalent sequestered per hectare per year when 1st-generation biofuel cropland is converted to perennial grasses.",
"type": "number"
}
}
},
"conventional": {
"additionalProperties": true,
"description": "Configuration for `conventional` settings.",
Expand Down Expand Up @@ -11139,6 +11184,11 @@
"description": "Add option for Direct Air Capture (DAC).",
"type": "boolean"
},
"perennials": {
"default": false,
"description": "Add option for perennialisation (converting 1st-generation biofuel cropland to perennial grasses) as a carbon dioxide removal (CDR) technology.",
"type": "boolean"
},
"co2_vent": {
"default": false,
"description": "Add option for vent out CO2 from storages to the atmosphere.",
Expand Down Expand Up @@ -12901,6 +12951,26 @@
}
}
},
"co2_removal_data": {
"description": "Configuration for a single data source.",
"properties": {
"source": {
"default": "archive",
"description": "Source of the data. 'archive' retrieves pre-built data, 'primary' retrieves from primary source.",
"enum": [
"archive",
"primary",
"build"
],
"type": "string"
},
"version": {
"default": "latest",
"description": "Version of the data to use. Uses the specific 'version' for the selected 'source' or the dataset tagged 'latest' for this source.",
"type": "string"
}
}
},
"co2stop": {
"description": "Configuration for a single data source.",
"properties": {
Expand Down
1 change: 1 addition & 0 deletions data/versions.csv
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ bidding_zones_electricitymaps,v1.238.0,primary,latest supported,2026-01-22,,http
bidding_zones_electricitymaps,v1.238.0,archive,latest supported,2026-01-22,,https://data.pypsa.org/workflows/eur/bidding_zones_electricitymaps/v1.238.0/world.geojson
bidding_zones_entsoepy,v0.6.18,primary,latest supported,2026-01-22,,https://raw.githubusercontent.com/EnergieID/entsoe-py/refs/tags/V0.6.18/entsoe/geo/geojson
bidding_zones_entsoepy,v0.6.18,archive,latest supported,2026-01-22,,https://data.pypsa.org/workflows/eur/bidding_zones_entsoepy/v0.6.18
co2_removal_data,v1.0.1,primary,latest supported,2026-06-22,"Afforestation and perennialisation input data for the CDR sector technologies, archived from https://github.com/BertoGBG/CO2-removal",https://zenodo.org/records/20799337/files/BertoGBG/CO2-removal-v1.0.1.zip
co2stop,26-august-2020,primary,latest supported,2025-12-02,,https://setis.ec.europa.eu/document/download/786a884f-0b33-4789-b744-28004b16bd1a_en?filename=co2jrc_openformats.zip
co2stop,26-august-2020,archive,latest supported,2026-01-13,,https://data.pypsa.org/workflows/eur/co2stop/26-august-2020/co2jrc_openformats.zip
copernicus_land_cover,v3.0.1,primary,latest supported,2025-12-02,"The primary is already from Zenodo, documentation in https://zenodo.org/records/4723921",https://zenodo.org/records/3939050/files/PROBAV_LC100_global_v3.0.1_2019-nrt_Discrete-Classification-map_EPSG-4326.tif
Expand Down
1 change: 1 addition & 0 deletions doc/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

<!-- Upcoming Release -->
<!-- ================= -->
* feat: Add perennialisation as a carbon dioxide removal (CDR) technology, converting 1st-generation biofuel cropland to perennial grasses, with node-level potential derived from NUTS2-resolved Eurostat crop yields ([#2143](https://github.com/PyPSA/pypsa-eur/issues/2143)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good and concise. Can we add the following sentence for completeness?

Perennialisation also provideds biogas, which is added to the biogas potential.


## PyPSA-Eur v2026.08.0 (19th August 2026)

Expand Down
8 changes: 8 additions & 0 deletions doc/sector.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ Having downloaded the necessary data,

::: build_biomass_potentials

## Rule `build_perennials_yields_eurostat_average`

::: build_perennials_yields_eurostat_average

## Rule `build_perennials_yields`

::: build_perennials_yields

## Rule `build_egs_potentials`

::: build_egs_potentials
Expand Down
37 changes: 37 additions & 0 deletions rules/build_sector.smk
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,38 @@ rule build_biomass_potentials:
scripts("build_biomass_potentials.py")


rule build_perennials_yields_eurostat_average:
input:
nuts2021=rules.retrieve_eu_nuts_2021.output.shapes_level_2,
crops_nuts2=rules.retrieve_co2_removal_data.output.eurostat_crops_nuts2,
crops_nuts0=rules.retrieve_co2_removal_data.output.eurostat_crops_nuts0,
costs=resources(f"costs_{config['costs']['year']}_processed.csv"),
output:
yields_all=resources("perennials_yields_1G_biofuels.csv"),
log:
logs("build_perennials_yields_eurostat_average.log"),
script:
scripts("build_perennials_yields_eurostat_average.py")


rule build_perennials_yields:
input:
nuts2=rules.retrieve_eu_nuts_2021.output.shapes_level_2,
country_shapes=resources("country_shapes.geojson"),
perennials_yields_1G_biofuels=resources("perennials_yields_1G_biofuels.csv"),
regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"),
output:
csv_file=resources("perennials_yields_1G_biofuels_s_{clusters}.csv"),
log:
logs("build_perennials_yields_s_{clusters}.log"),
resources:
mem_mb=8000,
params:
biomass=config_provider("biomass"),
script:
scripts("build_perennials_yields.py")


rule build_biomass_transport_costs:
input:
sc1="data/biomass_transport_costs_supplychain1.csv",
Expand Down Expand Up @@ -1722,6 +1754,11 @@ rule prepare_sector_network:
if config_provider("sector", "district_heating", "ates", "enable")(w)
else []
),
perennials_yields_1G_biofuels=lambda w: (
resources("perennials_yields_1G_biofuels_s_{clusters}.csv")
if config_provider("sector", "perennials")(w)
else []
),
output:
resources(
"networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc"
Expand Down
58 changes: 58 additions & 0 deletions rules/retrieve.smk
Original file line number Diff line number Diff line change
Expand Up @@ -1629,3 +1629,61 @@ if (MOBILITY_PROFILES_DATASET := dataset_version("mobility_profiles"))["source"]
run:
copy2(input["kfz"], output["kfz"])
copy2(input["pkw"], output["pkw"])


if (CO2_REMOVAL_DATASET := dataset_version("co2_removal_data"))["source"] in [
"primary",
"archive",
]:

rule retrieve_co2_removal_data:
input:
zip=storage(CO2_REMOVAL_DATASET["url"]),
output:
afforestation_nuts_biomass_densities=resources(
"afforestation_nuts_biomass_densities.xlsx"
),
afforestation_nuts2_afforestation_rates=resources(
"afforestation_rates_nuts2_full.csv"
),
afforestation_nuts2_monthly_weights=resources(
"afforestation_nuts2_monthly_weights.csv"
),
eurostat_crops_nuts2=resources("eurostat_apro_cpshr_nuts2_raw.csv"),
eurostat_crops_nuts0=resources("eurostat_apro_cpshr_nuts0_raw.csv"),
retries: 2
message:
"Downloading carbon dioxide removal data (afforestation, perennialisation inputs)"
run:
with ZipFile(input.zip) as z:
# GitHub's release archive nests everything under a single

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can remove these commented lines

# top-level "<owner>-<repo>-<commit-sha>/" folder whose name
# changes with every release, so resolve it at runtime.
top_dir = z.namelist()[0].split("/")[0]
for src_path, dest in [
(
"outputs/afforestation/afforestation_nuts_biomass_densities.xlsx",
output.afforestation_nuts_biomass_densities,
),
(
"outputs/afforestation/afforestation_rates_nuts2_full.csv",
output.afforestation_nuts2_afforestation_rates,
),
(
"outputs/afforestation/afforestation_nuts2_monthly_weights.csv",
output.afforestation_nuts2_monthly_weights,
),
(
"outputs/perennialisation/eurostat_apro_cpshr_nuts2_raw.csv",
output.eurostat_crops_nuts2,
),
(
"outputs/perennialisation/eurostat_apro_cpshr_nuts0_raw.csv",
output.eurostat_crops_nuts0,
),
]:
with (
z.open(f"{top_dir}/{src_path}") as src,
open(dest, "wb") as dst,
):
dst.write(src.read())
45 changes: 45 additions & 0 deletions scripts/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,51 @@ def load_costs(cost_file: str) -> pd.DataFrame:
return pd.read_csv(cost_file, index_col=0)


# 1G biofuel crop groups and their target biomass class names.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest moving the additions in _helpers.py to build_biomass_potentials. py

# In the upstream default config these sit in "not included".
# When sector: perennials=True, resolve_biomass_classes() moves them automatically.
ONE_G_BIOFUEL_CLASSES = {
"Bioethanol barley, wheat, grain maize, oats, other cereals and rye": "biofuels_1G_bioethanol_cereals",
"Sugar from sugar beet": "biofuels_1G_bioethanol_sugar",
"Rape seed": "biofuels_1G_biodiesel",
"Sunflower, soya seed ": "biofuels_1G_biodiesel",
}


def resolve_biomass_classes(classes, perennials_enabled):
"""
Auto-reallocate 1G biofuel crop groups from 'not included' into their
biofuels_1G_* target classes when perennials are enabled.

Cases:
perennials=False → return classes unchanged.
perennials=True, all groups in 'not included' → move them (normal case).
perennials=True, no groups in 'not included' → raise AssertionError.
perennials=True, some groups elsewhere → move available ones, warn about rest.
"""
if not perennials_enabled:
return classes
classes = copy.deepcopy(classes)
not_incl = classes.get("not included", [])
in_ni = [g for g in ONE_G_BIOFUEL_CLASSES if g in not_incl]
elsewhere = [g for g in ONE_G_BIOFUEL_CLASSES if g not in not_incl]
if not in_ni:
raise AssertionError(
"sector: perennials=true but no 1G-biofuel groups are in "
"biomass: classes: 'not included'. Restore upstream defaults "
"so the groups can be reallocated automatically."
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this AssertionError is difficult to understand, I suggest something like:
"sector: perennials=true but no 1G-biofuel groups are in "
"biomass: classes: 'not included'. If all 1G-biofuels are used "
"there is no land available for perennials. "

if elsewhere:
logger.warning(
"perennials: 1G groups already allocated outside 'not included' "
f"— skipped (running perennials only for available groups): {elsewhere}"
)
for g in in_ni:
classes["not included"].remove(g)
classes.setdefault(ONE_G_BIOFUEL_CLASSES[g], []).append(g)
return classes


def _simplify_polys(
polys, minarea=100 * 1e6, maxdistance=None, tolerance=None, filterremote=True
): # 100*1e6 = 100 km² if CRS is DISTANCE_CRS
Expand Down
21 changes: 13 additions & 8 deletions scripts/build_biomass_potentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
import numpy as np
import pandas as pd

from scripts._helpers import configure_logging, set_scenario_config
from scripts._helpers import (
configure_logging,
resolve_biomass_classes,
set_scenario_config,
)

logger = logging.getLogger(__name__)
AVAILABLE_BIOMASS_YEARS = [2010, 2020, 2030, 2040, 2050]
Expand Down Expand Up @@ -184,17 +188,15 @@ def disaggregate_nuts0(bio):
return bio


def build_nuts2_shapes():
def build_nuts2_shapes(nuts2_fn, country_shapes_fn):
"""
- load NUTS2 geometries
- add RS, AL, BA country shapes (not covered in NUTS 2013)
- consistently name ME, MK
"""
nuts2 = gpd.GeoDataFrame(
gpd.read_file(snakemake.input.nuts2).set_index("NUTS_ID").geometry
)
nuts2 = gpd.GeoDataFrame(gpd.read_file(nuts2_fn).set_index("NUTS_ID").geometry)

countries = gpd.read_file(snakemake.input.country_shapes).set_index("name")
countries = gpd.read_file(country_shapes_fn).set_index("name")
missing_iso2 = countries.index.intersection(["AL", "RS", "XK", "BA"])
missing = countries.loc[missing_iso2]

Expand Down Expand Up @@ -382,7 +384,7 @@ def add_unsustainable_potentials(df, input_eurostat):

enspreso = disaggregate_nuts0(enspreso)

nuts2 = build_nuts2_shapes()
nuts2 = build_nuts2_shapes(snakemake.input.nuts2, snakemake.input.country_shapes)

df_nuts2 = gpd.GeoDataFrame(nuts2.geometry).join(enspreso)

Expand All @@ -392,7 +394,10 @@ def add_unsustainable_potentials(df, input_eurostat):

df.to_csv(snakemake.output.biomass_potentials_all)

grouper = {v: k for k, vv in params["classes"].items() for v in vv}
classes = resolve_biomass_classes(
params["classes"], snakemake.config["sector"].get("perennials", False)
)
grouper = {v: k for k, vv in classes.items() for v in vv}
df = df.T.groupby(grouper).sum().T

input_eurostat = snakemake.input.eurostat
Expand Down
Loading
Loading