-
Notifications
You must be signed in to change notification settings - Fork 360
Temporal industry load #1875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Temporal industry load #1875
Conversation
for more information, see https://pre-commit.ci
|
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
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:
- 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 %
- 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
Notes
nodal_df (TWh/a) is calculated by multiplying nodal_sector ratios (MWh/t) * nodal_production (Mton/a) |
|
Comparison looks good! |
There was a problem hiding this 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: |
There was a problem hiding this comment.
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.
Co-authored-by: Fabian Neumann <[email protected]>
remove error handling from API call Co-authored-by: Fabian Neumann <[email protected]>
Co-authored-by: Fabian Neumann <[email protected]>
fneum
left a comment
There was a problem hiding this 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| industrial_loads.columns, | ||
| bus=industrial_elec_profiles.columns.tolist(), |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.






Closes # (if applicable).
Changes proposed in this Pull Request
Checklist
envs/environment.yaml.config/config.default.yaml.doc/configtables/*.csv.doc/data_sources.rst.doc/release_notes.rstis added.