Skip to content

feat: PEMMDB v2.4 brownfield integration — Phase 1 (thermal conventionals) - #86

Closed
pworschischek-aggmag wants to merge 0 commit into
mainfrom
feat/pemmdb-brownfield-phase1-pr
Closed

feat: PEMMDB v2.4 brownfield integration — Phase 1 (thermal conventionals)#86
pworschischek-aggmag wants to merge 0 commit into
mainfrom
feat/pemmdb-brownfield-phase1-pr

Conversation

@pworschischek-aggmag

@pworschischek-aggmag pworschischek-aggmag commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements Phase 1 of pypsa-at-planning#96: PEMMDB v2.4 as the EU brownfield capacity source for thermal conventional technologies.

Replaces PowerPlantMatching (PPM) capacity totals for non-AT countries with PEMMDB v2.4 data (the official ENTSO-E capacity database), while retaining PPM's spatial plant location information for multi-region countries (e.g. Germany).

Feature is disabled by default (mods.pemmdb_brownfield.enabled: false). No existing runs are affected.


Files changed

File Change
data/pypsa-at/tyndp_technology_map.csv New — PEMMDB → open-tyndp → pypsa-eur carrier mapping
scripts/pypsa-at/build_pemmdb_data.py New — reads PEMMDB xlsx, outputs long-format capacity CSV
rules/open-tyndp/build.smk New — build_pemmdb_data Snakemake rule
mods/network_updates.py New: apply_pemmdb_brownfield() + 2 private helpers
scripts/solve_network.py Post-solve hook: calls apply_pemmdb_brownfield() when enabled
rules/solve_myopic.smk Conditional pemmdb_capacities input when enabled
config/config.at.yaml New mods.pemmdb_brownfield config block (disabled)
test/test_pemmdb_brownfield.py Unit tests + parametrized contract tests (allowed to fail)

Architecture

retrieve_open_tyndp (PR #85, already merged)
  ↓  data/tyndp/archive/2024/PEMMDB2/{year}/PEMMDB_{node}_NationalTrends_{year}.xlsx

build_pemmdb_data  (new rule, rules/open-tyndp/build.smk)
  ↓  resources/pemmdb_capacities_{planning_horizons}.csv

apply_pemmdb_brownfield  (mods/network_updates.py)
  ↓  post-solve: sets p_nom_min on Links/Generators/StorageUnits
  ↓  exported network carries PEMMDB floors → next myopic horizon via add_brownfield.py

Carrier mapping (tyndp_technology_map.csv): closely mirrors the open-tyndp technology map used in Open-TYNDP PR #97, with pypsa_eur_carrier column added for PyPSA-AT/EUR conventions.

Multi-region distribution (e.g. Germany 5/16 nodes): PEMMDB national totals are distributed using existing PPM p_nom as spatial weights; falls back to uniform distribution if no PPM prior exists.

AT always skipped (skip_countries: ["AT"]): Austrian brownfield is tracked in a separate issue stream.


Phase scope

Phase Tech group Status
1 Thermal (Gas/CCGT/OCGT, Nuclear, Coal, Lignite, Oil) This PR
2 Renewables (Solar, Wind) 📌 Next
3 Electrolysers, Batteries 📌 Later
4 Investment trajectories 📌 Later

Testing

Unit tests in test/test_pemmdb_brownfield.py cover:

  • _set_p_nom_min_for_carrier(): Link, Generator, unknown carrier
  • _get_ppm_spatial_prior(): zero prior, known links
  • apply_pemmdb_brownfield(): disabled, wrong horizon, CCGT, nuclear, AT skip

Contract tests (test_pemmdb_brownfield_contract) are parametrized over all planning horizons and skip automatically when solved networks are not present (CI-safe). They are allowed to fail during early development as documented in the issue.

Closes part of AGGM-AG/pypsa-at-planning#96 (Phase 1).

Summary by Sourcery

Integrate PEMMDB v2.4 as an optional brownfield capacity source for thermal conventional technologies in non‑AT EU countries and wire it into the myopic solve workflow while keeping the feature disabled by default.

New Features:

  • Add a pipeline to build PEMMDB v2.4 thermal capacity tables from TYNDP Excel files into long-format CSV resources for selected planning horizons.
  • Introduce an optional post-solve network update that applies PEMMDB-based brownfield capacity floors to Links, Generators, and StorageUnits using PPM capacities as spatial weights where available.
  • Expose configuration for PEMMDB-based brownfield capacities via a new mods.pemmdb_brownfield config block, including enable flag, planning horizons, and skipped countries.

Enhancements:

  • Wire PEMMDB capacity tables into the myopic solve rule as conditional inputs and include new Open-TYNDP build rules in the main Snakefile to support the PEMMDB-based workflow.

Tests:

  • Add unit and contract tests for PEMMDB brownfield integration, covering helper functions, configuration conditions, AT skipping, and consistency between PEMMDB projections and solved networks.

@sourcery-ai

sourcery-ai Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements Phase 1 of PEMMDB v2.4 brownfield integration for thermal conventional technologies by building PEMMDB-based capacity tables from TYNDP data, wiring them into the Snakemake pipeline, and applying them post-solve as p_nom_min floors to non-AT thermal assets when enabled via configuration.

Sequence diagram for applying PEMMDB brownfield floors post-solve

sequenceDiagram
    participant SM as Snakemake
    participant Solve as solve_network.py
    participant Net as pypsa.Network
    participant Apply as apply_pemmdb_brownfield
    participant CSV as pemmdb_capacities_{planning_horizons}.csv
    participant Prior as _get_ppm_spatial_prior
    participant Setter as _set_p_nom_min_for_carrier

    SM->>Solve: call create_optimization_model
    Solve->>Net: build and solve network
    Solve->>Solve: check config.mods.pemmdb_brownfield.enabled
    alt PEMMDB brownfield enabled
        Solve->>Apply: apply_pemmdb_brownfield(Net, Snakemake)
        Apply->>Apply: read config and planning_horizon
        Apply->>Apply: validate horizon in planning_horizons
        Apply->>CSV: read PEMMDB capacities CSV
        CSV-->>Apply: capacities table
        Apply->>Apply: filter skip_countries and aggregate by bus,country,carrier
        loop for each (country,carrier)
            Apply->>Net: identify model buses for country
            Apply->>Prior: _get_ppm_spatial_prior(Net, buses, carrier)
            Prior-->>Apply: ppm_p_nom per bus
            Apply->>Apply: compute spatial weights (PPM or uniform)
            loop for each target bus
                Apply->>Setter: _set_p_nom_min_for_carrier(Net, bus, carrier, node_p_nom)
                Setter-->>Apply: updated_count
                Apply->>Apply: accumulate applied_count / skipped_count
            end
        end
        Apply-->>Solve: Net updated in place
    else PEMMDB brownfield disabled or horizon not allowed
        Solve->>Solve: skip apply_pemmdb_brownfield
    end
    Solve->>Net: export_to_netcdf
Loading

ER diagram for PEMMDB capacities table used for brownfield floors

erDiagram
    pemmdb_capacities {
        string bus
        string pemmdb_carrier
        string pemmdb_type
        string open_tyndp_carrier
        string open_tyndp_type
        string pypsa_eur_carrier
        float p_nom
        float e_nom
        float efficiency
        string country
        int planning_horizon
        string unit
    }

    buses {
        string bus
        string country
        string carrier
    }

    pemmdb_capacities ||--o{ buses : maps_to_model_bus
Loading

Flow diagram for PEMMDB v2.4 thermal brownfield data pipeline

flowchart LR
    subgraph OpenTYNDP_data
        A_retrieve_open_tyndp[retrieve_open_tyndp rule]
        A_xlsx["PEMMDB_{node}_NationalTrends_{year}.xlsx (per country/year)"]
    end

    subgraph Build_phase
        B_build_rule[build_pemmdb_data rule]
        B_script[build_pemmdb_data.py script]
        B_map[tyndp_technology_map.csv]
        B_csv["pemmdb_capacities_{planning_horizons}.csv"]
    end

    subgraph Solve_phase
        C_solve_rule[solve_sector_network_myopic rule]
        C_solve_script[solve_network.py]
        C_apply[apply_pemmdb_brownfield function]
        C_network["PyPSA Network (post-solve)"]
        C_export["Exported network (NetCDF) with p_nom_min floors"]
    end

    A_retrieve_open_tyndp --> A_xlsx

    A_xlsx --> B_build_rule
    B_build_rule --> B_script
    B_script --> B_map
    B_script --> B_csv

    B_csv --> C_solve_rule
    C_solve_rule --> C_solve_script

    C_solve_script --> C_network
    C_solve_script -->|if mods.pemmdb_brownfield.enabled and horizon allowed| C_apply

    B_csv --> C_apply
    C_apply -->|update p_nom_min on Links/Generators/StorageUnits| C_network
    C_network --> C_export
Loading

File-Level Changes

Change Details Files
Introduce post-solve PEMMDB brownfield application that overwrites p_nom_min for thermal technologies using PEMMDB capacities with PPM-based spatial weighting and AT exclusion.
  • Add apply_pemmdb_brownfield() in mods/network_updates.py to read PEMMDB capacity CSV, filter by configured horizons and countries, aggregate by (bus, carrier), and set component p_nom_min for Links/Generators/StorageUnits.
  • Implement _get_ppm_spatial_prior() to derive spatial weights from existing PPM p_nom across candidate buses for a carrier, with zero defaults where no assets exist.
  • Implement set_p_nom_min_for_carrier() to update p_nom_min on all matching components at a given bus/carrier and return the count of updated components.
  • Wire apply_pemmdb_brownfield() into scripts/solve_network.py as a post-solve hook conditional on mods.pemmdb_brownfield.enabled and planning_horizons.
  • Extend rules/solve_myopic.smk to pass pemmdb_capacities{planning_horizons}.csv as an optional input when the feature is enabled.
mods/network_updates.py
scripts/solve_network.py
rules/solve_myopic.smk
Add build step to derive long-format PEMMDB capacity tables for thermal technologies from TYNDP PEMMDB xlsx files using a technology mapping CSV.
  • Create scripts/pypsa-at/build_pemmdb_data.py to parse per-node PEMMDB Excel files, extract Thermal sheet capacities for configured thermal techs, aggregate, map to open-tyndp and pypsa-eur carriers via tyndp_technology_map.csv, and write resources/pemmdb_capacities_{planning_horizons}.csv.
  • Implement read_pemmdb_node() helper to load the correct PEMMDB Excel file per node/year with basic error handling.
  • Implement process_thermal_hydrogen_capacities() to clean and structure the Thermal sheet into a p_nom table by carrier/type with fixed efficiency and metadata.
  • Implement map_carriers() to merge PEMMDB carrier/type pairs against the technology map, logging warnings for unmapped combinations but retaining rows.
  • Add Snakemake rule build_pemmdb_data in rules/open-tyndp/build.smk that consumes retrieved PEMMDB directory and mapping CSV, filters nodes by skip_countries, and produces pemmdb_capacities{planning_horizons}.csv using the script.
  • Include rules/open-tyndp/build.smk from the main Snakefile so the build rule is available in the workflow.
scripts/pypsa-at/build_pemmdb_data.py
rules/open-tyndp/build.smk
Snakefile
Introduce configuration and data mapping for PEMMDB brownfield integration and protect it behind a disabled-by-default feature flag.
  • Add mods.pemmdb_brownfield block to config/config.at.yaml with enabled flag (default false), planning_horizons list, and skip_countries (default ["AT"]) to control when and where PEMMDB floors are applied.
  • Add data/pypsa-at/tyndp_technology_map.csv to map PEMMDB (carrier,type) combinations to open-tyndp and pypsa-eur carriers used by the build script and downstream filtering.
  • Ensure AT is always excluded from PEMMDB brownfield handling by default via config and build rule parameters, keeping Austrian brownfield in a separate flow.
config/config.at.yaml
data/pypsa-at/tyndp_technology_map.csv
Add unit and contract tests for PEMMDB brownfield behaviour, including synthetic network tests and optional full-pipeline contract checks.
  • Create test/test_pemmdb_brownfield.py with helpers to construct minimal pypsa.Network instances and a snakemake-like stub for feeding PEMMDB CSVs and config into apply_pemmdb_brownfield().
  • Add unit tests for _set_p_nom_min_for_carrier() covering Links, Generators, and unknown carriers, verifying correct p_nom_min updates and counts.
  • Add unit tests for _get_ppm_spatial_prior() verifying zero priors for unknown buses and correct aggregation for known CCGT links.
  • Add unit tests for apply_pemmdb_brownfield() that cover disabled mode, mismatched planning horizon, correct application of CCGT and nuclear capacities, and enforced skipping of AT.
  • Add parametrized contract test test_pemmdb_brownfield_contract that, when real PEMMDB CSVs and solved networks exist, checks p_nom_min against PEMMDB aggregates within a 5% tolerance, skipping automatically when inputs are missing.
test/test_pemmdb_brownfield.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • The helper _get_ppm_spatial_prior claims in its docstring to consider Links, Generators, and StorageUnits but currently only aggregates Links and Generators, so either include storage_units in the prior or adjust the docstring to match the implementation.
  • In solve_network.py you guard the call to apply_pemmdb_brownfield with a config check even though the function itself re-checks enabled and planning_horizons; consider relying on the internal checks only to avoid duplicated configuration logic.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The helper `_get_ppm_spatial_prior` claims in its docstring to consider Links, Generators, and StorageUnits but currently only aggregates Links and Generators, so either include `storage_units` in the prior or adjust the docstring to match the implementation.
- In `solve_network.py` you guard the call to `apply_pemmdb_brownfield` with a config check even though the function itself re-checks `enabled` and `planning_horizons`; consider relying on the internal checks only to avoid duplicated configuration logic.

## Individual Comments

### Comment 1
<location path="mods/network_updates.py" line_range="562-571" />
<code_context>
+    """
+    updated = 0
+
+    # Links (thermal plants: bus1 = electricity output)
+    links_mask = (n.links.carrier == carrier) & (n.links.bus1 == bus)
+    if links_mask.any():
+        n.links.loc[links_mask, "p_nom_min"] = p_nom_min
+        updated += links_mask.sum()
+
+    # Generators
+    gen_mask = (n.generators.carrier == carrier) & (n.generators.bus == bus)
+    if gen_mask.any():
+        n.generators.loc[gen_mask, "p_nom_min"] = p_nom_min
+        updated += gen_mask.sum()
+
+    # StorageUnits
+    su_mask = (n.storage_units.carrier == carrier) & (n.storage_units.bus == bus)
+    if su_mask.any():
+        n.storage_units.loc[su_mask, "p_nom_min"] = p_nom_min
+        updated += su_mask.sum()
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Using the full node `p_nom_min` for every component at a bus/carrier likely over-allocates capacity when multiple components exist.

For each `(bus, carrier)` you apply the same `p_nom_min` to every matching Link/Generator/StorageUnit. With multiple components at a node, this multiplies the effective minimum capacity (`node_p_nom × num_components`) rather than enforcing a single node-level floor from PEMMDB. It would be more accurate to allocate `p_nom_min` across components (e.g. proportionally to existing `p_nom` or evenly) so their summed `p_nom_min` matches the intended node-level `node_p_nom`.
</issue_to_address>

### Comment 2
<location path="mods/network_updates.py" line_range="523-532" />
<code_context>
+    """
+    Return the existing PPM-derived ``p_nom`` per bus as a spatial weight proxy.
+
+    Looks across Links, Generators, and StorageUnits for the given carrier.
+    Returns a Series indexed by bus with p_nom sums (0 if not present).
+    """
+    result = pd.Series(0.0, index=buses)
+
+    # Links: bus1 is the electricity output bus
+    links_mask = (n.links.carrier == carrier) & (n.links.bus1.isin(buses))
+    if links_mask.any():
+        for bus in buses:
+            bus_mask = links_mask & (n.links.bus1 == bus)
+            result[bus] = result[bus] + n.links.loc[bus_mask, "p_nom"].sum()
+
+    # Generators
+    gen_mask = (n.generators.carrier == carrier) & (n.generators.bus.isin(buses))
+    if gen_mask.any():
+        for bus in buses:
+            bus_mask = gen_mask & (n.generators.bus == bus)
</code_context>
<issue_to_address>
**issue (bug_risk):** The spatial prior ignores StorageUnits despite the docstring and caller expectations.

The code only considers Links and Generators, so StorageUnit capacity is excluded from the spatial weights despite what the docstring and caller expect. This can bias PEMMDB capacity allocation for carriers partly modeled as storage. Either include `n.storage_units` in the aggregation or update the docstring and callers so the behavior is consistent and not misleading.
</issue_to_address>

### Comment 3
<location path="scripts/pypsa-at/build_pemmdb_data.py" line_range="204-206" />
<code_context>
+        if caps is not None:
+            frames.append(caps)
+
+    if not frames:
+        logger.warning(f"No PEMMDB thermal capacities found for pyear={pyear}")
+        return pd.DataFrame()
+
+    all_caps = pd.concat(frames, ignore_index=True)
</code_context>
<issue_to_address>
**issue (bug_risk):** Returning an empty DataFrame with no columns will produce an empty CSV that `pd.read_csv` cannot parse later.

Here you return `pd.DataFrame()` with no columns, so `to_csv` will create an entirely empty file. The later `pd.read_csv(pemmdb_file)` call will then raise `EmptyDataError`. Please either:
- return an empty DataFrame with the expected columns so the CSV has headers, or
- avoid writing the file in this case and update downstream logic to treat a missing file as "no PEMMDB data".
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread mods/network_updates.py
Comment on lines +562 to +571
# Links (thermal plants: bus1 = electricity output)
links_mask = (n.links.carrier == carrier) & (n.links.bus1 == bus)
if links_mask.any():
n.links.loc[links_mask, "p_nom_min"] = p_nom_min
updated += links_mask.sum()

# Generators
gen_mask = (n.generators.carrier == carrier) & (n.generators.bus == bus)
if gen_mask.any():
n.generators.loc[gen_mask, "p_nom_min"] = p_nom_min

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Using the full node p_nom_min for every component at a bus/carrier likely over-allocates capacity when multiple components exist.

For each (bus, carrier) you apply the same p_nom_min to every matching Link/Generator/StorageUnit. With multiple components at a node, this multiplies the effective minimum capacity (node_p_nom × num_components) rather than enforcing a single node-level floor from PEMMDB. It would be more accurate to allocate p_nom_min across components (e.g. proportionally to existing p_nom or evenly) so their summed p_nom_min matches the intended node-level node_p_nom.

Comment thread mods/network_updates.py
Comment on lines +523 to +532
Looks across Links, Generators, and StorageUnits for the given carrier.
Returns a Series indexed by bus with p_nom sums (0 if not present).
"""
result = pd.Series(0.0, index=buses)

# Links: bus1 is the electricity output bus
links_mask = (n.links.carrier == carrier) & (n.links.bus1.isin(buses))
if links_mask.any():
for bus in buses:
bus_mask = links_mask & (n.links.bus1 == bus)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): The spatial prior ignores StorageUnits despite the docstring and caller expectations.

The code only considers Links and Generators, so StorageUnit capacity is excluded from the spatial weights despite what the docstring and caller expect. This can bias PEMMDB capacity allocation for carriers partly modeled as storage. Either include n.storage_units in the aggregation or update the docstring and callers so the behavior is consistent and not misleading.

Comment on lines +204 to +206
if not frames:
logger.warning(f"No PEMMDB thermal capacities found for pyear={pyear}")
return pd.DataFrame()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Returning an empty DataFrame with no columns will produce an empty CSV that pd.read_csv cannot parse later.

Here you return pd.DataFrame() with no columns, so to_csv will create an entirely empty file. The later pd.read_csv(pemmdb_file) call will then raise EmptyDataError. Please either:

  • return an empty DataFrame with the expected columns so the CSV has headers, or
  • avoid writing the file in this case and update downstream logic to treat a missing file as "no PEMMDB data".

Comment thread Snakefile
"pemmdb_capacities_{planning_horizons}.csv"
)
}
if config.get("mods", {})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Should use config getter function

Comment thread mods/network_updates.py
:
Updates ``n`` in place.
"""
cfg = snakemake.config.get("mods", {}).get("pemmdb_brownfield", {})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

require mods.pemmdb_brownfield key. Fail if missing

Comment thread mods/network_updates.py
skip_countries = cfg.get("skip_countries", ["AT"])
pemmdb_file = Path(snakemake.input.pemmdb_capacities)

if not pemmdb_file.is_file():

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

require the pemmdb capacities file, fail if missing.

Comment thread rules/solve_myopic.smk
"pemmdb_capacities_{planning_horizons}.csv"
)
}
if config.get("mods", {})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

use config getter helper function

@pworschischek-aggmag

Copy link
Copy Markdown
Collaborator Author

Critical Breaker detected: The PEMMDB data source does not contain brownfield capacities. It only contains projections for 2030+ and is used for lower capacity constraints in Open-TYNDP. I'll close this PR as I need to rethink the design

@pworschischek-aggmag
pworschischek-aggmag deleted the feat/pemmdb-brownfield-phase1-pr branch April 22, 2026 11:57
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.

1 participant