Skip to content

Add processes components as multi-link alternative#1333

Merged
FabianHofmann merged 48 commits intoPyPSA:masterfrom
coroa:feat/add-process-components
Mar 2, 2026
Merged

Add processes components as multi-link alternative#1333
FabianHofmann merged 48 commits intoPyPSA:masterfrom
coroa:feat/add-process-components

Conversation

@coroa
Copy link
Copy Markdown
Member

@coroa coroa commented Aug 15, 2025

Closes #549 .

Changes proposed in this Pull Request

Adds a new processes component as a multi-link alternative.

The difference to links is that efficiency, efficiency2, ... are replaced by rate0, rate1, rate2, ... to determine the energy output (always positive sign) at bus0. As well as a consistent sign convention, in that the power output at busX of the process is always rateX * p where p is the internal optimization variable.

This allows implicitly to choose the reference bus, by setting any rate to 1 or -1.

Take the example of the Haber-Bosch process, which is written with a (Multi-)Link as:

n.add(
    "Link",
    nodes,
    suffix=" Haber-Bosch",
    bus0=nodes,
    bus1=spatial.ammonia.nodes,
    bus2=nodes + " H2",
    p_nom_extendable=True,
    carrier="Haber-Bosch",
    efficiency=1 / costs.at["Haber-Bosch", "electricity-input"],
    efficiency2=-costs.at["Haber-Bosch", "hydrogen-input"]
    / costs.at["Haber-Bosch", "electricity-input"],
    capital_cost=costs.at["Haber-Bosch", "capital_cost"]
    / costs.at["Haber-Bosch", "electricity-input"],
    marginal_cost=costs.at["Haber-Bosch", "VOM"]
    / costs.at["Haber-Bosch", "electricity-input"],
    lifetime=costs.at["Haber-Bosch", "lifetime"],
)

with the Process component this becomes:

n.add(
    "Process",
    nodes,
    suffix=" Haber-Bosch",
    bus0=nodes,
    bus1=spatial.ammonia.nodes,
    bus2=nodes + " H2",
    p_nom_extendable=True,
    carrier="Haber-Bosch",
    rate0=-costs.at["Haber-Bosch", "electricity-input"],  # negative rate consumes
    rate1=1,   # positive rate produces; 1 sets the reference bus
    rate2=-costs.at["Haber-Bosch", "hydrogen-input"],
    capital_cost=costs.at["Haber-Bosch", "capital_cost"],
    marginal_cost=costs.at["Haber-Bosch", "VOM"],
    lifetime=costs.at["Haber-Bosch", "lifetime"],
)

The last commit 4210200 adds a variant of the sector coupling notebook replacing all Links (incl. a CHP Multilink) with Processes. The results are identical.

Todo

  • Couple of TODOs in the code, @lkstrp Would you mind reviewing already, whether i made obvious mistakes (or fucked up any of your excellent component work)
  • Documentation needs to be updated
  • Plotting functions need to work with Process too

Checklist

  • Documentation updates
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@coroa coroa force-pushed the feat/add-process-components branch from 4210200 to d71e67d Compare August 15, 2025 17:49
@coroa
Copy link
Copy Markdown
Member Author

coroa commented Aug 15, 2025

Rebased to the actual master now. Had an old state. The test failures are deprecations in the master branch.

@coroa coroa force-pushed the feat/add-process-components branch from d71e67d to 24efddb Compare August 16, 2025 09:57
@euronion
Copy link
Copy Markdown
Contributor

Instead of setting rateX=1 to specify the reference bus, how about specifying the reference bus explicitly, e.g. with

reference_bus="bus1"

That way, the rates have no implied meaning and users do not need to calculate the rates relative to one particular rate (one less calculation step that can be erroneous).

Say in your example you have the inputs and outputs of the process in absolute values, then you could plug these values directly (dummy values, with approx 5 MWh/t ammonia)

n.add(
    "Process",
    nodes,
    suffix=" Haber-Bosch",
    bus0=nodes,
    bus1=spatial.ammonia.nodes,
    bus2=nodes + " H2",
    p_nom_extendable=True,
    carrier="Haber-Bosch",
    rate0=-1.2,
    rate1=5,
    rate2=-5.5,
    reference_bus="bus1",
    capital_cost=costs.at["Haber-Bosch", "capital_cost"],
    marginal_cost=costs.at["Haber-Bosch", "VOM"],
    lifetime=costs.at["Haber-Bosch", "lifetime"],
)

@coroa
Copy link
Copy Markdown
Member Author

coroa commented Sep 2, 2025

Hi, @euronion, the reference_bus would then be for fixing the units for capital and marginal costs?

Efficiencies are already fully determined by the ratios of the rates. Not sure what this would then do? I think i fail to understand what would be the input data that would lead you to set

   rate0=-1.2,
   rate1=5,
   rate2=-5.5

do factsheets of a process not always report efficiencies as ratios between ports?

In your example, the process is producing 5 t of ammonia, by consuming 1.2 MWh of electricity and 5.5t H2. Did you mean that? And marginal costs are expected as Eur/5t ammonia or Eur/1.2MWhel.

Setting the reference bus to bus1 would then allow you to specify it as Eur/t ammonia, i guess.

@euronion
Copy link
Copy Markdown
Contributor

euronion commented Sep 8, 2025

Hi, @euronion, the reference_bus would then be for fixing the units for capital and marginal costs?

Efficiencies are already fully determined by the ratios of the rates. Not sure what this would then do? I think i fail to understand what would be the input data that would lead you to set

   rate0=-1.2,
   rate1=5,
   rate2=-5.5

do factsheets of a process not always report efficiencies as ratios between ports?

In your example, the process is producing 5 t of ammonia, by consuming 1.2 MWh of electricity and 5.5t H2. Did you mean that? And marginal costs are expected as Eur/5t ammonia or Eur/1.2MWhel.

Setting the reference bus to bus1 would then allow you to specify it as Eur/t ammonia, i guess.

Yes, you understood that correctly.
While there are some cases where technology data sheets are not normed, my suggestion primarily stems from the urge to not hide and imply the "reference_bus" information based on the rate_X=1 setting, but to make it explicit.

@coroa
Copy link
Copy Markdown
Member Author

coroa commented Sep 8, 2025

I'll wait for #1349 to land and then rebase on that.

@lkstrp
Copy link
Copy Markdown
Member

lkstrp commented Sep 11, 2025

Couple of TODOs in the code, @lkstrp Would you mind reviewing already, whether i made obvious mistakes (or fucked up any of your excellent component work)

I only skimmed this, not questioning the processes component, but you are adhering to the component architecture wonderfully 👍

@fneum fneum added this to the Feature candidates milestone Dec 10, 2025
@coroa
Copy link
Copy Markdown
Member Author

coroa commented Feb 4, 2026

Instead of reference_bus="bus0", we introduce relative_to="bus1" to make it explicit, to be provided as required argument.

@lkstrp lkstrp moved this from Todo to In Progress in PyPSA - Roadmap Feb 4, 2026
@FabianHofmann
Copy link
Copy Markdown
Contributor

FabianHofmann commented Feb 5, 2026

Instead of reference_bus="bus0", we introduce relative_to="bus1" to make it explicit, to be provided as required argument.

@coroa I thought about it again: I imagine use cases where you have a process component with a reference unit, however the sector of that reference unit is not in the system (or not any more). For example you want to model haber-bosch without the explicit ammonia sector, just model the hydrogen and electricity consumption, potentially keeping the same dispatch of the HB asset from a previous run. In that case you would loose the flexibility to use the original rate data and have to convert all units to either hydrogen or electricity.

Therefore, the way I see it, most flexibility you gain with discarding the reference bus and allow for arbitrary rate values. The output of the process would contain a p (along with p0, p1, ...) which itself indicates the internal, reference operation of the process. If rate<n>=1 then p==p<n> . however the latter is not a requirement. Like this you allow for arbitrary selections of in- and outputs of your process while keeping the same input data. Does that make sense?

@coroa
Copy link
Copy Markdown
Member Author

coroa commented Feb 6, 2026

Instead of reference_bus="bus0", we introduce relative_to="bus1" to make it explicit, to be provided as required argument.

@coroa I thought about it again: I imagine use cases where you have a process component with a reference unit, however the sector of that reference unit is not in the system (or not any more). For example you want to model haber-bosch without the explicit ammonia sector, just model the hydrogen and electricity consumption, potentially keeping the same dispatch of the HB asset from a previous run. In that case you would loose the flexibility to use the original rate data and have to convert all units to either hydrogen or electricity.

Therefore, the way I see it, most flexibility you gain with discarding the reference bus and allow for arbitrary rate values. The output of the process would contain a p (along with p0, p1, ...) which itself indicates the internal, reference operation of the process. If rate<n>=1 then p==p<n> . however the latter is not a requirement. Like this you allow for arbitrary selections of in- and outputs of your process while keeping the same input data. Does that make sense?

well I agree with you, and this internal dispatch is also already in the code, since it made sense anyway, but that is not what was decided.

@FabianHofmann
Copy link
Copy Markdown
Contributor

well I agree with you, and this internal dispatch is also already in the code, since it made sense anyway, but that is not what was decided.

I think this was not fully clear to the other (at least it wasn't to me). The flexibility would be very helpful and we should go for it. @lkstrp @brynpickering @euronion let's reconsider? perhaps @fneum also has a view on this

@euronion
Copy link
Copy Markdown
Contributor

euronion commented Feb 9, 2026

🚫 I'm still strongly in favour of making this information explicit, not implicit.

✔️ I do see the point that you are making for cases where the reference is not available or part of the system/process.

An option could be a compromise, where we allow overloading of relative_to to be either the reference bus (to avoid the user having to specify redundant values) or the reference rate explicitly:

relative_to: str | float = "bus1"
# or
relative_to: str | float = <p>

@coroa
Copy link
Copy Markdown
Member Author

coroa commented Feb 9, 2026

I took a stab at an implementation of relative_to today,

n.add(
    "Process",
    nodes,
    suffix=" Haber-Bosch",
    bus0=nodes,
    bus1=spatial.ammonia.nodes,
    bus2=nodes + " H2",
    p_nom_extendable=True,
    carrier="Haber-Bosch",
    rate0=-costs.at["Haber-Bosch", "electricity-input"],  # negative rate consumes
    rate1=1,   # positive rate produces; 1 sets the reference bus
    rate2=-costs.at["Haber-Bosch", "hydrogen-input"],
    capital_cost=costs.at["Haber-Bosch", "capital_cost"],
    marginal_cost=costs.at["Haber-Bosch", "VOM"],
    lifetime=costs.at["Haber-Bosch", "lifetime"],

    relative_to="bus2",
)

Ie. the internal dispatch variable is synced with the ammonia output; the relative_to is choosing the hydrogen input as the reference.

I think this means, that the marginal_cost should be in - x Eur/t hydrogen (minus sign, because of an input).

Does this mean that p_nom_opt is then expected in t hydrogen?

if so, i think that means i need to actually rescale all the rate variables internally by that rate2:
rateX_effective = rateX_nom / rate2.

@coroa
Copy link
Copy Markdown
Member Author

coroa commented Feb 10, 2026

An option could be a compromise, where we allow overloading of relative_to to be either the reference bus (to avoid the user having to specify redundant values) or the reference rate explicitly:

relative_to: str | float = "bus1"
# or
relative_to: str | float = <p>

That's a bit complicated by the fact that we are starting to allow also just the port numbers as integers as valid port signifiers, as introduced in #1386 .

If we tried to be consistent, then this would mean relative_to=2 would mean relative to rate2 and relative_to=2. would mean relative to a rate of 2.0.

@euronion
Copy link
Copy Markdown
Contributor

I didn't know about the port and discussion in the other PR. Is it a good idea to allow just the port numbers as integers? I understand it is convenient, at the same time it reduces our degrees of freedom for future development (maybe?).

Having relative_to=2 and relative_to=2.0 behave differently seems like a bad idea to me.
If port is being used already anyways, what about reference_port = str | int?

Shall we revisit this discussion next Wednesday and collect the options and ideas again by then?

@lkstrp
Copy link
Copy Markdown
Member

lkstrp commented Feb 12, 2026

I think this was not fully clear to the other (at least it wasn't to me). The flexibility would be very helpful and we should go for it. @lkstrp @brynpickering @euronion let's reconsider? perhaps @fneum also has a view on this

I talked with both Fabians again, and @fneum is also very much in favor of keeping this implicit without the need for an extra attribute. Just bring one of your rates to 1 and all good. I think this is also more intuitive and simpler. I am also against any of those mixed solutions where reference_to could be optional. Since @coroa and @FabianHofmann see it the same way I guess the vote would have changed already anyway. Could still discuss that next week (again) if you think it's needed

@euronion
Copy link
Copy Markdown
Contributor

After the arguments that @brynpickering shared about making it explict I was under the impression last time that explicit should be preferred.

But feel free to go ahead if that is the current majority vote.

@FabianHofmann
Copy link
Copy Markdown
Contributor

thanks for being flexible @euronion on this (it's always a back and forth, isn't it). if we see there the interpretation is too complicated we still can add explicit things later

@FabianHofmann
Copy link
Copy Markdown
Contributor

@lkstrp could you have a look at the multiports.py. I had to pull in some types to collect the shared functions, don't know how clean that is. also I am importing the Multiports from _types in descriptors.py (better avoided?)

Copy link
Copy Markdown
Member

@lkstrp lkstrp left a comment

Choose a reason for hiding this comment

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

A couple of comments. Multiport wants to inherit from Components and be a Mixin, that will also clean up all the patched type stuff

@FabianHofmann
Copy link
Copy Markdown
Contributor

@lkstrp @coroa I addressed all the open comments and closed them after addressing (otherwise I would have lost track). I will look out for the CI now, but otherwise this seems good to go

@FabianHofmann
Copy link
Copy Markdown
Contributor

I would add one more test and "bug fix" that columns from attributes from additional port are appearing after there predecessor

@fneum
Copy link
Copy Markdown
Member

fneum commented Feb 27, 2026

The Process component looks very neat and elegant. Excellent!

I have one request for the finishing touches before merging:

The Process component is currently absent from the formulations in the user guide (i.e. subsequent sections of https://pypsa--1333.org.readthedocs.build/1333/user-guide/optimization/overview/). Could you complete this with the Process component everywhere? Some elements can probably be handled similarly jointly as Line and Transformer component.

SPDX-License-Identifier: CC-BY-4.0
-->

::: pypsa.components.Multiport
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No, we don't wanna expose this, methods here should be inherited to Links and Processes and shown there. I can just fix that

@FabianHofmann
Copy link
Copy Markdown
Contributor

The Process component looks very neat and elegant. Excellent!

I have one request for the finishing touches before merging:

The Process component is currently absent from the formulations in the user guide (i.e. subsequent sections of https://pypsa--1333.org.readthedocs.build/1333/user-guide/optimization/overview/). Could you complete this with the Process component everywhere? Some elements can probably be handled similarly jointly as Line and Transformer component.

good point! done

@FabianHofmann FabianHofmann merged commit 1cf65b4 into PyPSA:master Mar 2, 2026
26 checks passed
@github-project-automation github-project-automation bot moved this from In Progress to Done in PyPSA - Roadmap Mar 2, 2026
@FabianHofmann FabianHofmann removed the gap label Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Adaption of multilink implementation

5 participants