Skip to content

add Perennialisation as Carbon Dioxide Removal Technology and Biogas producer - #2274

Open
BertoGBG wants to merge 11 commits into
PyPSA:masterfrom
BertoGBG:perennialisation_PR
Open

BertoGBG wants to merge 11 commits into
PyPSA:masterfrom
BertoGBG:perennialisation_PR

Conversation

@BertoGBG

@BertoGBG BertoGBG commented Aug 20, 2026 •

Copy link
Copy Markdown

Closes # (if applicable).

This PR introduces Perennialisation as Carbon Dioxide Removal (CDR) technology, based on #2143

Perennialisation is defined as the process of replacing biofuels crops (1st generation unsustainable crops in pypsa-eur) with perennial crops (as clover or grass) which differ from lignocellulosic short rotation coppice (as willow or poplar). The overall substitution process is assumed to reduce the CO2 emission by direct removal and sequestration in the carbon soil (as perennials are not harvested every season) and significant reduction in fertilizers and nitrogen emissions compared to seasonal crops. The perennials crops are refined to two products: biogas (added to the model) and proteins for animals (which are assumed to be sold reducing the operational cost of the plant).

The substitution is based on equal land use converting the biofuels crops from Enspreso to perennials green crops using yields derived form Eurostat (using NUTS2 resolution for both). The input data for the yields of both 1st generation biofuels crops and perennials crops are pre-processed and compiled. They available at the zenodo record https://zenodo.org/records/20799337 (includes also afforestation CDR for a future PR) which is downloaded by the rule retrieve_co2_removal_data within retrieve.smk.

The calculation of the CO2 removal potential based on substitution is done by the rules, located in build_sector.smk :
build_perennials_yields_eurostat_average (and matching script)
build_perennials_yields (and matching script)

The whole process of CDR and biogas production is introduced within prepare_sector_network.py .

The default.config.yaml contains the main parameters for calculating the CDR potential (including the key assumption on the sequestration_co2 per hectare per year), and sets perennialisation default to false.

NOTE on Technology-data version.

Data dependency note (technology-data)

add_perennials() requires the "perennials refining" cost entries, which are not part of technology-data v0.14.0 — the version currently pinned in data/versions.csv (costs/"latest supported"). There is an ongoing PR for merging those cost data at PyPSA/technology-data#255

Checklist

Required:

  • [OK] Changes are tested locally and behave as expected.
  • [OK] Code and workflow changes are documented.
  • [OK] A release note entry is added to doc/release_notes.md.
  • [OK] The description is human-written and any AI-generated content is marked.

If applicable:

  • [OK] Changes in configuration options are reflected in scripts/lib/validation.
  • [OK] For new data sources or versions, these instructions have been followed.
  • [OK] New rules are documented in the appropriate doc/*.md files.

BertoGBG and others added 9 commits June 23, 2026 09:28
Ported from the a_CDRs development branch. Adds perennialisation
(conversion of 1st-generation biofuel cropland to perennial grasses) as
an optional CDR technology: land area displaced from 1G biofuel
production is backed out from existing biomass potentials and NUTS2 crop
yields, converted to a CO2 sequestration potential, and dispatched via an
April-October harvest profile. Retrieves NUTS2 Eurostat crop-yield data
from the same Zenodo-archived CO2-removal data package afforestation
will also depend on (rules/retrieve.smk: retrieve_co2_removal_data, kept
byte-identical to avoid conflicts whichever PR merges first).

Off by default (sector.perennials: false).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the pattern set by rock_weathering_PR (scripts/lib/validation/config/rock_weathering.py):

- New scripts/lib/validation/config/perennials.py with PerennialsConfig
  (sequestration_co2), registered on the top-level ConfigSchema in
  _schema.py.
- sector.py: added sector.perennials: bool (the CDR technology toggle).
- data.py: added data.co2_removal_data as a _DataSourceConfig (feeds
  the retrieve_co2_removal_data rule's Eurostat crop yield inputs).
- Regenerated config/config.default.yaml and config/schema.default.json
  from the new models; both config-schema sync tests now pass (they
  were failing before, same root cause as biochar_PR's still-open gap:
  perennials config wasn't covered by any pydantic model).

Also added a release_notes.md entry matching rock_weathering_PR's
"Upcoming Release" bullet style, referencing tracking issue PyPSA#2143.

This commit was preceded by a merge of upstream/master (57 commits
behind at the time) to pull in the validation framework this depends
on. That merge had exactly one conflict, in scripts/_helpers.py: both
branches had independently appended unrelated functions to the end of
the file (resolve_biomass_classes() here vs. _simplify_polys()/
load_data_versions() upstream) - resolved by keeping both.

Full test suite (29 tests) and ruff both pass. Verified via Snakemake
dry-run with sector.perennials temporarily set to true that the full
77-job DAG resolves end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mh2EaBSg63Gsi4epA1Rmky
@BertoGBG

Copy link
Copy Markdown
Author

@martavp @fneum

BertoGBG and others added 2 commits August 25, 2026 10:04
…gbr"

The technology-data source (BertoGBG/technology-data pypsa-eur_AA branch)
names this row "perennials gbr" (Grass BioRefinery), not "perennials
refining". add_perennials() was looking up the wrong name, which would
crash with KeyError: 'perennials refining' as soon as a run actually
exercised this path. Already fixed on the `perennialisation` branch;
ported here and to a_CDRs/heat_industry.

@martavp martavp left a comment

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.

Thanks @BertoGBG , I left small changes and also run it locally, and it works fine.

In addition:

(a) build_perennials_yields and build_perennials_yields_eurostat_average.py include too many comments (from Claude, I guess) that need to be removed, though they can be useful to keep within the review process.

(b) Could you add in the PR description a short description of why convert_nuts2_to_regions_yields from build_biomass_potentials.py can’t be directly used? (i.e. what we discussed via email)

Comment thread doc/release_notes.md

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

n.add(
"Link",
nodes,
suffix=" perennials refining",

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.

why not
suffix=" co2 perennials"
as the rest of the elements?

snakemake.input.perennials_yields_1G_biofuels
).set_index("name")

# calculate perennials potential based on the conversion on first generation biofuels for equal area

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.

Suggested change
# calculate perennials potential based on the conversion on first generation biofuels for equal area
# calculate potential of CO2 removed via perennials based on the conversion on first generation biofuels for equal area

n.add(
"Link",
nodes,
suffix=" perennials refining",

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.

Suggested change
suffix=" perennials refining",
suffix=" co2 perennials",

n.add(
"Store",
nodes,
suffix=" CO2s perennials",

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.

Suggested change
suffix=" CO2s perennials",
suffix=" co2 perennials",

Comment thread rules/retrieve.smk
"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

Comment thread scripts/_helpers.py
"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. "

Comment thread scripts/_helpers.py
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

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

# Address the missign countries (df_nuts2 contains all NUTS2 + missing shapes (with NaNs))
missing_countries = ["AL", "RS", "BA", "XK"]

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.

when running only selected countries, this gives an error

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

# Address the missign countries (df_nuts2 contains all NUTS2 + missing shapes (with NaNs))
missing_countries = ["AL", "RS", "BA", "XK"]

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.

Suggested change
missing_countries = ["AL", "RS", "BA", "XK"]
missing_countries = df_nuts2.index.intersection(["AL", "RS", "BA", "XK"])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants