Skip to content

Conversation

@JulianGeis
Copy link
Contributor

@JulianGeis JulianGeis commented Nov 3, 2025

Closes # (if applicable).

Changes proposed in this Pull Request

Checklist

  • I tested my contribution locally and it works as intended.
  • Code and workflow changes are sufficiently documented.
  • Changed dependencies are added to envs/environment.yaml.
  • Changes in configuration options are added in config/config.default.yaml.
  • Changes in configuration options are documented in doc/configtables/*.csv.
  • Sources of newly added data are documented in doc/data_sources.rst.
  • A release note doc/release_notes.rst is added.

@JulianGeis
Copy link
Contributor Author

JulianGeis commented Nov 3, 2025

This PR adds support for temporal (hourly) industrial electricity demand profiles

Current behavior: Industrial electricity loads are modeled as constant flat loads (annual demand divided by hours).

New feature: When enabled via the config flag temporal_electricity_industry_load: true, the model:

  1. Downloads real load profiles from the FfE (Forschungsstelle für Energiewirtschaft) open data API for different industry sectors (steel, cement, chemicals, paper, etc.) -> Link (https://opendata.ffe.de/dataset/normalized-industrial-electrical-load-profiles-germany/)

  2. Creates node-specific profiles by weighting sector profiles according to each node's industrial production mix and energy intensity

  3. Maps profiles to simulation timeframe by matching day-of-week and hour patterns from the 2017 reference year

  4. Validates consistency by ensuring hourly profiles sum to the same annual demand as before

This allows the model to capture realistic temporal variations in industrial electricity consumption (e.g., weekday/weekend patterns, shift schedules), which is important for accurate grid planning and renewable energy integration studies.

Normalized Load profiles:

image - The values are **dimensionless** - they represent the relative load pattern normalized to integrate to 1 over the year. This means: - The sum of all 8760 hourly values for each industry branch equals 1.0 - Each value represents the **fraction of total annual load** occurring in that specific hour - To get actual energy consumption, you would multiply these normalized values by the total annual energy consumption for that industry branch - Paper: https://www.ffe.de/wp-content/uploads/2021/11/Wie-koennen-europaeische-Branchen-Lastgaenge-die-Energiewende-im-Industriesektor-unterstuetzen.pdf

Relative to average load in %

image - e.g. stell has little deviations wheras mining / transport has higher deviations - daily and weekly profiles exist; slighlty different values for different monthly / seasons

Mapping

  • the mapping maps the daily and weekly pattern from the reference year 2017 in which the profiles are to the year used (here 2013)
image

Notes
Relevant inputs:

  • nodal_df: electricity consumption of industry per node in TWh
  • nodal_sector_ratios: nodal electricity consumption in MWh/tMaterial for different industry sectors
  • nodal_production: material demand per node and industry (Mton/a)

nodal_df (TWh/a) is calculated by multiplying nodal_sector ratios (MWh/t) * nodal_production (Mton/a)

@JulianGeis
Copy link
Contributor Author

JulianGeis commented Nov 4, 2025

Examples for 365H run

image

3H run results

image

What have I tested?

  • workflow runs with default settings and 50 nodes in 365H and 3H for 2030 and 2045
  • Overall sum of loads are equal (until rounding 2 decimals in TWh bc of saving with only two decimals in non temporal case)
  • Daily and weekly profile makes visually sense (lower on weekends, day and night profile)
  • Sum of profiles’ volatilities weighted by the amount one industry has at a node is roughly equal (or lower) to the final load volatility in terms of daily and weekly volatility

@JulianGeis JulianGeis marked this pull request as ready for review November 4, 2025 12:32
@fneum fneum added this to the v2025.11.0 milestone Nov 5, 2025
@JulianGeis
Copy link
Contributor Author

Some adaptations

Industrial demands for the whole system in 2030 and 2050
image

  • I changed the profiles for industrial profiles that are not in the ffe data from "Non-specified (Industry)" to profiles with less volatility that are closer to their real life operation

Some final testing on 3H resolution

  • 2030, 2045

system cost:

  • 2030: before: 769.42 Mrd €; after: 771.68 Mrd €; difference: 2.27 Mrd € (0.3 %)
  • 2045: before: 794.31 Mrd €; after: 797.05 Mrd €; difference: 2.74 Mrd € (0.3 %)

energy balance (diff of n_new - n_old in energy supply (n.statistics.supply())

  • 2030; in TWh (only printed if absolute diff > 1 TWh)
    component carrier
    Generator Offshore Wind (AC) -5.775976
    Offshore Wind (DC) 30.033801
    Onshore Wind -4.081143
    Solar -2.505021
    gas -1.134703
    nuclear 2.907199
    oil primary 1.005150
    solar rooftop -32.243501
    Line AC 6.561237
    Link DC 17.284844
    Open-Cycle Gas -3.745161
    battery charger 6.194843
    battery discharger 6.069681
    electricity distribution grid 14.330783
    gas pipeline new 4.020953
    home battery charger 3.131703
    home battery discharger 3.068430
    rural gas boiler 1.635867
    urban central gas CHP -22.957647
    urban central gas boiler 12.401556
    urban central resistive heater -8.350630
    urban central solid biomass CHP 10.097725
    urban central water pits charger -4.934687
    urban central water pits discharger -4.892858
    urban decentral air heat pump 2.945944
    urban decentral biomass boiler -5.463347
    urban decentral gas boiler 18.004273
    urban decentral resistive heater -12.490040
    StorageUnit Pumped Hydro Storage -3.986153
    Store Battery Storage 6.199533
    EV battery -1.686478
    gas -3.853268
    home battery 3.151445
    urban central water pits -4.747050
    dtype: float64

  • 2045:
    component carrier
    Generator Offshore Wind (AC) -7.641278
    Offshore Wind (DC) -26.594911
    Onshore Wind 27.237053
    Solar 4.813791
    biogas 10.843997
    gas -9.347044
    nuclear 6.194771
    oil primary 7.305688
    solar-hsat -24.775813
    Line AC 5.502215
    Link BEV charger 2.176873
    DAC -1.704307
    DC 12.668546
    Fischer-Tropsch -7.314679
    H2 Electrolysis -9.143604
    H2 pipeline 32.405502
    Open-Cycle Gas -4.815939
    V2G 1.959186
    battery charger 16.264212
    battery discharger 15.935608
    biogas to gas 10.843997
    electricity distribution grid -11.479128
    gas pipeline 6.328298
    oil refining 7.031257
    urban central air heat pump -4.372845
    urban central gas boiler 11.036632
    urban central resistive heater -6.233251
    urban central water pits charger -5.919406
    urban central water pits discharger -5.933886
    urban decentral air heat pump 5.808474
    urban decentral resistive heater -5.321739
    StorageUnit Pumped Hydro Storage -2.251506
    Store Battery Storage 16.257432
    EV battery -6.621250
    H2 Store -6.269449
    urban central water pits -5.994031
    dtype: float64

withdrawal

  • 2030
    component carrier
    Line AC 6.376707
    Link DC 17.770981
    Open-Cycle Gas -5.963633
    battery charger 6.322585
    battery discharger 6.194843
    biomass to liquid -2.420082
    electricity distribution grid 14.774003
    gas pipeline new 4.020953
    home battery charger 3.196281
    home battery discharger 3.131703
    oil refining 1.005150
    rural gas boiler 1.376993
    urban central gas CHP -21.699099
    urban central gas boiler 10.017412
    urban central resistive heater -8.434980
    urban central solid biomass CHP 9.225036
    urban central water pits charger -4.934687
    urban central water pits discharger -4.892858
    urban decentral air heat pump 1.130823
    urban decentral biomass boiler -6.208349
    urban decentral gas boiler 15.155112
    urban decentral resistive heater -13.877822
    StorageUnit Pumped Hydro Storage -5.315197
    Store Battery Storage 6.199533
    EV battery -1.686478
    gas -3.853268
    home battery 3.151445
    urban central water pits -4.788879
    dtype: float64

  • 2045

  • component carrier
    Line AC 6.951774
    Link BEV charger 2.418748
    DAC -5.027705
    DC 13.086690
    Fischer-Tropsch -10.537545
    H2 Electrolysis -12.452542
    H2 pipeline 32.663652
    Open-Cycle Gas -7.668693
    V2G 2.176873
    battery charger 16.599592
    battery discharger 16.264212
    biogas to gas 12.991109
    electricity distribution grid -11.834153
    gas pipeline 6.382698
    oil refining 7.305688
    urban central air heat pump -1.915849
    urban central gas boiler 8.914889
    urban central resistive heater -6.296213
    urban central water pits charger -5.919406
    urban central water pits discharger -5.933886
    urban decentral air heat pump 2.668876
    urban decentral resistive heater -5.913043
    StorageUnit Pumped Hydro Storage -3.003222
    Store Battery Storage 16.257432
    EV battery -6.621250
    H2 Store -6.269449
    urban central water pits -5.979551
    dtype: float64

  • results can be found on z1: scratch/htc/jgeis/pypsa-eur/results/2025-11-07-industrial-load

@fneum
Copy link
Member

fneum commented Nov 10, 2025

Comparison looks good!

Copy link
Member

@fneum fneum left a comment

Choose a reason for hiding this comment

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

A few things to optimize / simplify, but generally the structure and approach looks great! Mostly about vectorizing the code and using fewer (nested) for loops (which does not scale well). Merge after #1675 (as it introduces new data).

nodal_profiles = pd.DataFrame(index=snapshots, columns=nodal_df.index, dtype=float)

# For each node, create weighted profile
for node in nodal_df.index:
Copy link
Member

Choose a reason for hiding this comment

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

This will not scale super well with larger networks. Better would be something vectorized.

Copy link
Member

@fneum fneum left a comment

Choose a reason for hiding this comment

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

Nice that the holidays are accounted for now! I had some suggestions to simplify the code and make it more compact. See if these work and adapt accordingly.

There were quite some code changes to the initial version. Once you're done with the suggested revisions, could you repeat your initial verification?

- graphviz >=12.2.1
- gurobi >=12.0.3
- highspy >=1.12.0
- holidays >=0.87
Copy link
Member

Choose a reason for hiding this comment

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

This file is now automatically handled, based on pixi.toml (where this new dependency should be added instead).


def create_nodal_electricity_profiles(
nodal_df, nodal_sector_df, snapshots, path_to_ffe_json
nodal_df, nodal_sector_df, snapshots, ffe_profiles, industry_category_to_profile=INDUSTRY_CATEGORY_TO_PROFILE
Copy link
Member

Choose a reason for hiding this comment

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

The constant INDUSTRY_CATEGORY_TO_PROFILE does not need to be passed through. You can directly access it.

Comment on lines +4968 to +4969
industrial_loads.columns,
bus=industrial_elec_profiles.columns.tolist(),
Copy link
Member

Choose a reason for hiding this comment

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

This is not very safe because for it to function properly, industrial_loads and industrial_elec_profiles must be in order. Just align the indices, then you do not need to work with lists.


# Map profile to snapshots with correct day-of-week alignment
hourly_profile = map_profile_to_snapshots(node_profile, snapshots)
hourly_profile = map_profile_to_snapshots(node_profile, snapshots, node_country=node[:2], tol=0.02) # Tolerance increased to 2% to account for holiday replacement and date adjustments
Copy link
Member

Choose a reason for hiding this comment

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

Instead of node[:2], use node.map(n.buses.country) to avoid string inferral.

):
"""Create hourly electricity demand profiles for each node."""

# Industry category to FfE profile mapping (updated to match correct profile names)
Copy link
Member

Choose a reason for hiding this comment

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

To be done.

return nodal_profiles


def map_profile_to_snapshots(reference_profile, snapshots, node_country='DE', tol=0.02):
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, there is a lot of complicated datetime multi-index arithmetic going on here which I think can be avoided and be a bit shorter. I sketched the following solution (based on a pd.Series), which should work:

import pandas as pd
from holidays import country_holidays

s = pd.read_csv("scripts/machinery.csv", index_col=0, parse_dates=True).squeeze()

TARGET_YEAR = 2020
SOURCE_YEAR = 2017

# compute average day profiles
average_days = s.groupby([s.index.dayofweek, s.index.hour]).mean()

# normalise holidays

holidays = pd.to_datetime(list(country_holidays("DE", years=SOURCE_YEAR).keys()))
is_holiday = s.index.normalize().isin(holidays)

s.loc[is_holiday] = (
    s.loc[is_holiday]
    .index.map(lambda i: average_days.loc[(i.dayofweek, i.hour)])
    .to_numpy()
)

# add day drift
day_drift = (
    pd.Timestamp(SOURCE_YEAR, 1, 1).dayofweek
    - pd.Timestamp(TARGET_YEAR, 1, 1).dayofweek
)

ts = s.shift(day_drift, freq="D")
ts.index = ts.index.map(lambda t: t.replace(year=TARGET_YEAR))
ts.sort_index(inplace=True)

if pd.Timestamp(TARGET_YEAR, 1, 1).is_leap_year:
    ts.index = ts.index.where(ts.index.month < 3, ts.index - pd.Timedelta(days=1))

    extra_day_i = pd.date_range(f"{TARGET_YEAR}-12-31", periods=24, freq="h")

    extra_day = average_days.loc[extra_day_i[0].dayofweek]
    extra_day.index = extra_day_i
    ts = pd.concat([ts, extra_day]).sort_index()

# impose other country / year holidays
country = "NO"

holidays = pd.to_datetime(list(country_holidays(country, years=TARGET_YEAR).keys()))
is_holiday = ts.index.normalize().isin(holidays)

ts.loc[is_holiday] = (
    ts.loc[is_holiday].index.map(lambda i: average_days.loc[(6, i.hour)]).to_numpy()
)

# drop leap day
drop_leap_day = True  # config setting
if drop_leap_day and ts.index.is_leap_year.any():
    ts = ts[~((ts.index.month == 2) & (ts.index.day == 29))]

# scale to unity sum
ts /= ts.sum()

I don't think you need to worry about the leap day dropping until the end.

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.

3 participants