Skip to content

Feat: electricity load splitting - #175

Open
pworschischek-aggmag wants to merge 41 commits into
mainfrom
feat/update-electricity-demand
Open

Feat: electricity load splitting#175
pworschischek-aggmag wants to merge 41 commits into
mainfrom
feat/update-electricity-demand

Conversation

@pworschischek-aggmag

@pworschischek-aggmag pworschischek-aggmag commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Changes proposed in this Pull Request

Intermediate merge to save the mods/demand/{carrier}.py structure and 2 upstream bug fixes in main.

The branch is not to be deleted. It will live on and contain electricity load updates.

Checklist

Required:

  • Changes are tested locally and behave as expected.
  • All Sourcery Bot review suggestions have been implemented or discarded with an explanation.
  • All github actions succeed.

Summary by Sourcery

Introduce sectoral splitting of electricity base load and make TYNDP mappings dependent on clustering configuration.

New Features:

  • Add electricity demand update module that splits the base load into sectoral Loads for residential, services, transport, and agriculture.
  • Configure Austria scenario with yearly electricity demand totals used to scale and validate sectoral base load splitting.
  • Add marimo notebook for analyzing electricity base load deduction and sectoral splitting behavior.

Enhancements:

  • Adjust network preparation to apply electricity base load splitting using population-weighted energy totals and to clip negative sectoral Loads consistently.
  • Make TYNDP location mappings for trajectories, inflows, hydro capacities, and transmission trajectories depend on administrative clustering and custom island splits.
  • Update H2 import bus mappings to vary with administrative clustering levels and refine handling of edge-case negative hydrogen and electricity Loads.
  • Extend Sankey plotting and evaluation configs to recognise and visualise new sectoral electricity carriers with appropriate colour schemes.
  • Tighten energy totals filling logic to use grouped forward/backward filling and clarify distribution key configuration for NUTS3 electricity demand building.

Build:

  • Wire clustering-related parameters into Snakemake rules for solving sector networks and building hydro inflows, capacity trajectories, and TYNDP transmission data.

Documentation:

  • Document electricity base load deduction and sectoral splitting workflow via a marimo analysis notebook.
  • Add SPDX license headers and brief documentation comments to new demand module and updated config files.

Tests:

  • Update trajectory, inflow, capacity, and transmission trajectory tests to use the new clustering-aware TYNDP location resolution.
  • Add placeholder electricity demand tests to compare configured yearly totals with solved network Loads.
  • Adjust test configs and solve/build rules to pass clustering parameters required by the new mapping functions.

@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Splits the electricity base load into sector-specific demand carriers (including a new rail load) and wires this into the sector network preparation, while making clustering-dependent TYNDP location resolution reusable across inflow, capacity and trajectory pipelines, fixing several demand and trajectory edge cases and updating AT config/plotting to support the new loads.

Sequence diagram for electricity base load splitting and clipping

sequenceDiagram
    actor Snakemake
    participant prepare_sector_network
    participant base_load_load_splitting
    participant modify_prenetwork
    participant clip_negative_loads_for_edge_cases

    Snakemake->>prepare_sector_network: prepare_sector_network(n, snakemake, nodes, costs, spatial, pop_weighted_energy_totals)
    prepare_sector_network->>base_load_load_splitting: base_load_load_splitting(n, pop_weighted_energy_totals)
    base_load_load_splitting->>base_load_load_splitting: add sectoral Loads with BASE_LOAD_CARRIERS
    base_load_load_splitting->>base_load_load_splitting: replace agriculture electricity flat Loads
    base_load_load_splitting-->>prepare_sector_network: network with sectoral Loads

    Snakemake->>modify_prenetwork: modify_prenetwork(n, snakemake)
    modify_prenetwork->>clip_negative_loads_for_edge_cases: clip_negative_loads_for_edge_cases(n, snakemake)
    clip_negative_loads_for_edge_cases->>clip_negative_loads_for_edge_cases: _clip_electricity(location) uses BASE_LOAD_CARRIERS
    alt test-sector-myopic-at10 and investment_year < 2030
        clip_negative_loads_for_edge_cases->>clip_negative_loads_for_edge_cases: _clip_static("H2 for industry")
    else other runs
        clip_negative_loads_for_edge_cases->>clip_negative_loads_for_edge_cases: clip electricity Loads for negative hours
    end
Loading

File-Level Changes

Change Details Files
Introduce electricity base-load splitting into sectoral Loads and integrate it into sector network preparation.
  • Add mods.demand.electricity module with BASE_LOAD_CARRIERS and base_load_load_splitting that redistributes nodal base electricity Load time series into residential, services, road, rail, and agriculture carriers using JRC-IDEES-based energy totals.
  • Wire base_load_load_splitting into prepare_sector_network, passing pop_weighted_energy_totals from prepare_sector_network.py so splitting runs after other AT-specific modifications.
  • Ensure agriculture electricity loads are converted from flat Loads to profiled time series while conserving total energy (with per-node rescaling factors logged), and remove the original base-load Load components once fully distributed.
mods/network/common.py
scripts/prepare_sector_network.py
mods/demand/electricity.py
Adjust negative load clipping to operate on split electricity base-load carriers and refine hydrogen industry edge-case handling.
  • Modify electricity negative-load clipping to act on all Loads at a given location whose carrier is one of BASE_LOAD_CARRIERS, checking for any negative hours before clipping to zero.
  • Move clip_negative_loads_for_edge_cases call later in modify_prenetwork so clipping happens just before the solve step, matching the final demand profiles.
  • Restrict H2 for industry negative-load clipping in the at10 test network to investment years before 2030, avoiding unnecessary clipping in later years.
mods/network/common.py
Make TYNDP location mapping clustering-aware and reuse it across trajectories, inflow, capacity and transmission pipelines and tests.
  • Add ISLAND_SPLIT_NODES and resolve_tyndp_locations helper that collapses island split nodes to country codes based on admin_levels and custom clustering state.
  • Refactor trajectory aggregation to take an explicit location_mapping argument instead of using TYNDP_TO_PYPSA_LOCATION directly, updating apply_pemmdb_trajectories to resolve the mapping via resolve_tyndp_locations and passing it through.
  • Update inflow-per-region processing, capacity trajectories building, TYNDP transmission trajectories building, and corresponding tests to use resolve_tyndp_locations (or an injected mapping) and to propagate admin_levels/custom_clustering via Snakemake params and rules.
mods/constants.py
mods/network/trajectories.py
scripts/pypsa-at/build_inflow_totals_per_region.py
scripts/pypsa-at/build_capacity_trajectories.py
scripts/pypsa-at/build_tyndp_transmission_trajectories.py
mods/constraints/tyndp.py
rules/pypsa-at/build.smk
rules/open-tyndp/build.smk
rules/pypsa-at/solve.smk
rules/pypsa-at/modify.smk
test/test_mods/network/trajectories/test_trajectories_unit.py
test/test_mods/network/trajectories/test_trajectories_statistics.py
test/test_build_trajectories_capacity.py
test/test_build_inflow_totals_per_region.py
test/test_build_tyndp_transmission_trajectories.py
Update AT configuration, plotting and evaluation metadata to support new electricity-for-rail/road/residential/services carriers and yearly demand totals.
  • Add SPDX license header and change run.prefix in config.at.yaml to base-load-updates, and introduce mods.demand.electricity yearly totals for Austria by planning horizon years.
  • Extend plotting.at.yaml with colors for electricity for rail/road/residential/services matching sector color themes.
  • Update evals/config.default.toml carrier-group mappings so new electricity for rail/road/residential/services carriers are grouped correctly in base-load and transport aggregations.
  • Adjust Sankey plotting to aggregate new electricity for rail and electricity for road along with BEV charger for TRANSPORT, and new residential/services electricity carriers together with existing rural decentral electricity for HH_SERVICES.
config/config.at.yaml
config/plotting.at.yaml
evals/config.default.toml
evals/plots/sankey.py
Fix bugs and edge cases in distribution-key computation and energy totals filling logic.
  • Change nuts3_distribution_keys to require explicit gdp and population weights via distribution_key["gdp"] and distribution_key["population"], aligning keys with NUTS3 GeoJSON attributes.
  • Update fill_missing_years to use groupby(level="country").transform(ffill/bfill) so forward/backward filling happens per country group while preserving index alignment.
scripts/build_electricity_demand_base.py
scripts/build_energy_totals.py
Add clustering-dependent bus mappings for TYNDP H2 import landing nodes.
  • Replace static bus_mappings with build_bus_mappings that selects DE and IT landing nodes based on admin_levels (NUTS1 or custom clustering levels).
  • Inject admin_levels into the TYNDP H2 import capacities Snakemake rule and use build_bus_mappings in build_tyndp_h2_imports.py when replacing bus1 codes.
scripts/open-tyndp/build_tyndp_h2_imports.py
rules/open-tyndp/build.smk
Introduce exploratory documentation notebook for electricity base-load deduction chain and sectoral splitting.
  • Add .marimo/electricity-base-load.py, a marimo app that reconstructs the base-load deduction stages (raw, after heat, after industry) and shows descriptive statistics, sectoral weights, and load-duration curves, matching the new base_load_load_splitting implementation.
  • Ensure the notebook uses resources from the base-load-updates AT_KN2040 scenario and documents how negative loads propagate and are later clipped.
.marimo/electricity-base-load.py
Add initial test scaffolding for electricity demand vs. solved network loads and SPDX headers for demand modules.
  • Create mods.demand package with SPDX-licensed init.py, and add test_mods/demand/test_electricity.py that reads mods.demand.electricity configuration for AT and asserts alignment via require_config, serving as a starting point for future demand vs. network validation.
  • Ensure new demand modules carry SPDX license headers consistent with existing configuration modules.
mods/demand/__init__.py
test/test_mods/demand/test_electricity.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 4 issues, and left some high level feedback:

  • In base_load_load_splitting, consider using more specific exception types and explicitly handling nodes with zero base_energy to avoid division-by-zero when computing rail_share.
  • The change in nuts3_distribution_keys from .get to direct dictionary access makes distribution_key['gdp'] and ['population'] mandatory; if this is intended, add an explicit upfront validation of these keys so failures produce a clear, targeted error message.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `base_load_load_splitting`, consider using more specific exception types and explicitly handling nodes with zero `base_energy` to avoid division-by-zero when computing `rail_share`.
- The change in `nuts3_distribution_keys` from `.get` to direct dictionary access makes `distribution_key['gdp']` and `['population']` mandatory; if this is intended, add an explicit upfront validation of these keys so failures produce a clear, targeted error message.

## Individual Comments

### Comment 1
<location path="mods/demand/electricity.py" line_range="45-54" />
<code_context>
+    """
+    nodes = pop_weighted_energy_totals.index
+
+    base_load_idx = n.loads.query("carrier == 'electricity'").index
+    base_load = n.loads_t["p_set"][base_load_idx]
+
+    # sanity check: both indices contain the same entries
+    if any(differences := base_load.columns.symmetric_difference(nodes)):
+        raise Exception(
+            f"Electricity base load and electricity rail indices are not identical: {differences}"
+        )
+
+    # nodal annual energy of the (residual) base load in MWh/a
+    weightings = n.snapshot_weightings.generators
+    base_energy = base_load.mul(weightings, axis="index").sum()
+    rail_energy = (
+        pop_weighted_energy_totals["electricity rail"].mul(nyears).mul(1e6)
+    )  # to MWh/a
+    rail_share = rail_energy / base_energy
+
+    # sanity check: the rail share must be a true fraction of the base load,
</code_context>
<issue_to_address>
**issue:** Handle zero or near-zero base load energy to avoid invalid rail shares and division issues.

When `base_energy` is zero or extremely small, `rail_share` becomes `inf`/`NaN`. `NaN` will slip through the bounds check, and `inf` will raise an unclear error. This is realistic for islands or nodes without base load. Please explicitly handle zero/near-zero `base_energy` (e.g. skip splitting, cap `rail_energy` at `base_energy`, or raise a specific error) so the behaviour is well-defined and avoids numerical artefacts.
</issue_to_address>

### Comment 2
<location path="mods/demand/electricity.py" line_range="48-52" />
<code_context>
+    base_load_idx = n.loads.query("carrier == 'electricity'").index
+    base_load = n.loads_t["p_set"][base_load_idx]
+
+    # sanity check: both indices contain the same entries
+    if any(differences := base_load.columns.symmetric_difference(nodes)):
+        raise Exception(
+            f"Electricity base load and electricity rail indices are not identical: {differences}"
+        )
</code_context>
<issue_to_address>
**suggestion:** Use more specific exception types and clarify the mismatch context in the error message.

This sanity check currently raises a generic `Exception` and mentions "electricity rail indices" even though it’s comparing `base_load.columns` with `nodes` from `pop_weighted_energy_totals`. Consider raising a more appropriate type (e.g. `ValueError`) and explicitly naming both sides of the comparison in the message, e.g. `raise ValueError("Mismatch between electricity base-load columns and energy-totals nodes: ...")` to make data/configuration issues easier to diagnose.

```suggestion
    # sanity check: both indices contain the same entries
    if any(differences := base_load.columns.symmetric_difference(nodes)):
        raise ValueError(
            "Mismatch between electricity base-load columns and "
            f"pop_weighted_energy_totals nodes: {differences}"
        )
```
</issue_to_address>

### Comment 3
<location path="scripts/build_electricity_demand_base.py" line_range="154-155" />
<code_context>

-    gdp_weight = distribution_key.get("gdp", 0.6)
-    pop_weight = distribution_key.get("pop", 0.4)
+    gdp_weight = distribution_key["gdp"]
+    pop_weight = distribution_key["population"]

     nuts3 = gpd.read_file(nuts3_fn).to_crs(epsg=3035)
</code_context>
<issue_to_address>
**suggestion:** Removing default weights makes the function stricter; consider explicit validation of `distribution_key`.

Direct indexing will now raise `KeyError` for missing `distribution_key` entries. If this stricter behavior is intended, add a validation step that checks required keys up front and raises a clearer `ValueError` with guidance, so configuration issues are easier to diagnose than a bare `KeyError` from this line.

```suggestion
    required_keys = {"gdp", "population"}
    missing_keys = required_keys - set(distribution_key.keys())
    if missing_keys:
        raise ValueError(
            f"distribution_key is missing required keys: {', '.join(sorted(missing_keys))}. "
            "Expected a mapping like {'gdp': 0.6, 'population': 0.4}."
        )

    gdp_weight = distribution_key["gdp"]
    pop_weight = distribution_key["population"]
```
</issue_to_address>

### Comment 4
<location path="test/test_mods/demand/test_electricity.py" line_range="26-27" />
<code_context>
+        The location under ``mods.demand.{location}`` to compare.
+    """
+    cfg = require_config(nc, "mods", "demand", "electricity")
+    expected = cfg[location]  # KeyError on misalignment of config and parametrize
+    print(expected)
</code_context>
<issue_to_address>
**issue (testing):** Test has no assertions and will always pass, which makes it misleading as a verification of electricity demand.

This test only loads and prints the configuration without checking the solved network or demand values, so it will pass even if electricity demand is wrong. Add assertions that compare the yearly total electricity demand from the solved network (e.g., summing `n.loads_t['p']` or `p_set` over snapshots, weighted appropriately) against `cfg[location]` within a tolerance. If the test is not ready yet, mark it with `@pytest.mark.xfail(..., strict=True)` or skip it rather than letting a no-op test pass.
</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/demand/electricity.py Outdated
Comment thread mods/demand/electricity.py
Comment thread scripts/build_electricity_demand_base.py
Comment thread test/test_mods/demand/test_electricity.py
@pworschischek-aggmag

Copy link
Copy Markdown
Collaborator Author

@sourcery-ai review

@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 1 issue, and left some high level feedback:

  • The change in nuts3_distribution_keys from distribution_key.get('pop', ...) to distribution_key['population'] will now raise on missing keys; double-check all call sites and configs to ensure they pass the new {"gdp": ..., "population": ...} structure rather than the old "pop" key.
  • Since base_load_load_splitting removes the original electricity Loads and replaces them with sectoral carriers, review downstream code and analyses that may still assume a per-node carrier=='electricity' base load to avoid subtle breakage or misclassification.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The change in `nuts3_distribution_keys` from `distribution_key.get('pop', ...)` to `distribution_key['population']` will now raise on missing keys; double-check all call sites and configs to ensure they pass the new `{"gdp": ..., "population": ...}` structure rather than the old `"pop"` key.
- Since `base_load_load_splitting` removes the original `electricity` Loads and replaces them with sectoral carriers, review downstream code and analyses that may still assume a per-node `carrier=='electricity'` base load to avoid subtle breakage or misclassification.

## Individual Comments

### Comment 1
<location path="scripts/build_electricity_demand_base.py" line_range="154-155" />
<code_context>

-    gdp_weight = distribution_key.get("gdp", 0.6)
-    pop_weight = distribution_key.get("pop", 0.4)
+    gdp_weight = distribution_key["gdp"]
+    pop_weight = distribution_key["population"]

     nuts3 = gpd.read_file(nuts3_fn).to_crs(epsg=3035)
</code_context>
<issue_to_address>
**issue (bug_risk):** Accessing distribution_key with hard-coded keys may break existing configs that still use the previous 'pop' key.

Previously this function accepted `distribution_key` dicts with `"gdp"` and `"pop"` keys via `.get` defaults. Changing to `distribution_key["gdp"]` and `distribution_key["population"]` will raise `KeyError` for existing configs that still use `"pop"`. If backward compatibility is needed, either accept both keys (e.g. `.get("population", distribution_key.get("pop"))`) or add explicit validation with a clear error message about the required schema.
</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 on lines +154 to +155
gdp_weight = distribution_key["gdp"]
pop_weight = distribution_key["population"]

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): Accessing distribution_key with hard-coded keys may break existing configs that still use the previous 'pop' key.

Previously this function accepted distribution_key dicts with "gdp" and "pop" keys via .get defaults. Changing to distribution_key["gdp"] and distribution_key["population"] will raise KeyError for existing configs that still use "pop". If backward compatibility is needed, either accept both keys (e.g. .get("population", distribution_key.get("pop"))) or add explicit validation with a clear error message about the required schema.

Comment thread evals/plots/sankey.py
[
"rural",
"decentral",
"'electricity'",

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 be deleted

Comment thread mods/constraints/tyndp.py
import pandas as pd
import pypsa

from mods.constants import resolve_tyndp_locations

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 move functions to mods/utils

Comment thread mods/constants.py
"""
if mapping is None:
mapping = TYNDP_TO_PYPSA_LOCATION
collapse = {

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.

refactor to simplify the logic for human readibility

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