Skip to content

Endogenous Industry Heat#1943

Open
LukasFrankenQ wants to merge 27 commits into
PyPSA:masterfrom
lukas-develops:endog
Open

Endogenous Industry Heat#1943
LukasFrankenQ wants to merge 27 commits into
PyPSA:masterfrom
lukas-develops:endog

Conversation

@LukasFrankenQ

@LukasFrankenQ LukasFrankenQ commented Dec 11, 2025

Copy link
Copy Markdown
Collaborator

pypsa/pypsa-eur#611

Adds industry process heat demands heat100-200, heat200-500 and heat>500 by subtracting them from industry biomass and methane demands

if

config["sector"]["industry_t"]["endogen"] == True.

Temperature splits are process-aware, based on Fraunhofer ISI research Fleiter et al. (2025), Hydrogen Infrastructure in the Future CO2-Neu....

The PR does not cover steelworks. Its endogenisation is in a separate PR.

Changes to the Model

With industry heat endogenous, all existing industry-related components remain, heat demands are purely subtracted from biomass and methane industry demands, and are added as separate loads with the respective supply opportunities.

image

The changes to prepare_sector_network are largely identical to the previous PR by @lisazeyen, and the heat generation technologies added are the same. The temperature bands are chosen to match potentially interesting alternative renewable heat supply such as solar thermal and geothermal, which could be added to the model if desired.

image

The logic for exogenous heating remains untouched. There was a very sensible proposition by @koen-vg to set the exogenous heat supply mix in the config, as is done with industry production pathways. However, the volume of content this approach would add to the config was, in my estimation, too large.
Therefore, this PR introduces a new rule that builds (planning-horizons–unaware) heat-endogenous ratios and then simply infers endogenous industry demands (including the temperature bands) in build_industry_energy_demand_per_node, following the same logic as exogenous demand using ratios_intermediate.
As a result, industry_energy_demands_per_node consists essentially two concatenated dataframes. Level 0 in columns == "exogenous" contains the old dataframe and == "endogenous" is the new one.
Based on the endogenisation-switch in config, prepare_sector_network pulls the respective columns.

image

Towards Validation

Comparing exogenous and endogenous 2030 industry demands shows that the PR removes the vast majority of (industry) solid biomass and most of (industry) methane demand. Exogenous heat presumes electrification, likely explaining why the total energy demand is lower in the exogenous case.

image

The PR uses Fleiter et al. (2025) to estimate temperature band distributions for each industry process, and uses alternative process-specific sources to fill data gaps in between. Again, no steelworks in this PR.

convey_pr_content_2

These are very distinct between industries; however, they still yield largely similar temperature distributions between countries:

image

Spatial heterogeneity might only manifest in higher spatial resolutions.

Here, and in the previous iterations, we are aiming to align with Agora's estimate of industry heating demand at a total of around 1850 TWh/a:

image

Considering we are not including steelworks, we are doing quite well:

convey_pr_content_3

ToDos and Food for Thought

  • Need to ensure process emissions, HVC disaggregation, current electricity, etc. are synergising correctly with current methods.
  • After talking to @Irieo, it could be very valuable to either introduce a switch that endogenises only a subset of industries, or even represent each industry sector individually.
  • Think about synergy with @toniseibold’s steel endogenisation and the addition of individual cement production sites.
  • Other supply technologies: currently, we only consider electrified heating in heat100-200 and low-temperature heat, but in some sectors, electrification of higher-temperature demands is feasible.
  • As noted by @toniseibold, there appears to be some inconsistency in the biomass boiler's efficiency.

Happy about any feedback, and all the best,
Lukas

@lisazeyen @millingermarkus @Irieo @koen-vg @toniseibold @fneum @bobbyxng @p-glaum

PS: Are the pictures readable, or do they throw an error when clicked on?

Checklist

  • I tested my contribution locally and it works as intended.
  • Code and workflow changes are sufficiently documented.
  • [N/A] Changed dependencies are added to pixi.toml (using pixi add <dependency-name>).
  • Changes in configuration options are added in config/config.default.yaml.
  • Changes in configuration options are documented in doc/configtables/*.csv.
  • For new data sources or versions, these instructions <https://pypsa-eur.readthedocs.io/en/latest/data_sources.html>_ have been followed.
  • A release note doc/release_notes.rst is added.

LukasFrankenQ and others added 16 commits December 18, 2025 16:00
Endogenous industry sector ratios:
- Swap the Pulp/Paper PRODCOM mappings, which were crossed (pulp ->
  paper products, paper -> pulp products).
- Fix the pharmaceuticals backup share: heat>500 was 1.0, making the row
  sum to 2.0; set to 0.0 so the bands sum to 1.0.
- Override the Fleiter 2025 Mechanical pulp row (100% heat<100) with
  100% heat100-200, reflecting TMP/CTMP refiner steam at 130-170 C
  (Rehfeldt et al. 2018).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LukasFrankenQ
LukasFrankenQ marked this pull request as ready for review June 16, 2026 20:30
@LukasFrankenQ

LukasFrankenQ commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

@fneum @Irieo @toniseibold I think this is now in a quite good state, and could maybe even be merged, some notes:

  1. @Irieo By default it now applies endogenous heating to all industry sectors (except steel), but the config allows them to be switched on or off individually, so you could do an endogenous heat treatment of any subset of industry sectors.
  2. @fneum A central point we had talked about half a year ago was about the accounting of biomass emissions--that should now be fixed, biomass boilers are wired directly to spatial.biomass.
  3. @toniseibold There are definitely some conflicts with endogenous cement, but I would argue its most practical to deal with it after of our PRs is merged, if that is cool :P [ insert meme about first-PR-to-merge ]
  4. @lkstrp there is this weird deletion of a full file, please let me know if that is a concern, I am being told that its okay
image

@w-tusche w-tusche left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for working on this and pushing it further!

Upon testing the PR code for an own project I ran in to the problem that options and spatial in the new functions in prepare_sector_network.py are global. My suggestion is to pass these as arguments to the functions (as is done for options in the other functions in prepare_sector_network.py and as I suggest in PR2232 for spatial).

Some further comments on a stray pass and a minor unnecessarily complex if condition.

bus2 and bus3 are not consistent throught out the newly added links:
Sometimes it is:
bus2="co2 atmosphere",
bus3=spatial.co2.nodes,
(e.g. ["industry_t"]["heat100-200"]["biomass"])

other times:
bus2=spatial.co2.nodes,
bus3="co2 atmosphere",
(e.g., "heat200-500" biomass and methane links)

"""

logger.info("Add industry heat in the temperature band 100-200 °C.")
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function stops here, remove pass.

)


def add_t_industry100_200(n, nodes, industrial_demand, costs, must_run):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add options, spatial as parameters:
def add_t_industry100_200(n, nodes, industrial_demand, costs, must_run, options, spatial):


if (
options["industry_t"]["heat100-200"]["biomass"]
or not options["industry_t"]["endogen"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cleanup, or not is not required, as "endogen" is required for "heat100-200".

if options["industry_t"]["heat100-200"]["biomass"]:

options["industry_t"]["heat100-200"]["biomass"]
or not options["industry_t"]["endogen"]
):
logger.info(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Open question

)


def add_t_industry200_500(n, nodes, industrial_demand, costs, must_run):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add options, spatial as parameters:

def add_t_industry200_500(n, nodes, industrial_demand, costs, must_run, options, spatial):

p_nom_extendable=True,
p_min_pu=must_run,
efficiency=costs.at["direct firing gas", "efficiency"],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It seems like the lifetime is missing. (lifetime=costs.at["direct firing gas", "lifetime"],)


must_run = options["industry_t"]["must_run"]

add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pass arguments options and spatial add_t_industry100_200(n, nodes, industrial_demand, costs, must_run, options, spatial)

must_run = options["industry_t"]["must_run"]

add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)
add_t_industry200_500(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pass arguments options and spatial add_t_industry200_500(n, nodes, industrial_demand, costs, must_run, options, spatial)


add_t_industry100_200(n, nodes, industrial_demand, costs, must_run)
add_t_industry200_500(n, nodes, industrial_demand, costs, must_run)
add_t_industry500(n, nodes, industrial_demand, costs, must_run)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pass arguments options and spatial add_t_industry500(n, nodes, industrial_demand, costs, must_run, options, spatial)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants