Skip to content

Fix: ramp_limit_start_up attribute is ignored in binary UC#1540

Closed
Irieo wants to merge 13 commits intomasterfrom
fix-UC-rampsu
Closed

Fix: ramp_limit_start_up attribute is ignored in binary UC#1540
Irieo wants to merge 13 commits intomasterfrom
fix-UC-rampsu

Conversation

@Irieo
Copy link
Copy Markdown
Contributor

@Irieo Irieo commented Jan 28, 2026

Closes # #592 (considering the implications of the bug, this should have rather been high priority)

Problem

The ramp_limit_start_up and ramp_limit_shut_down attributes were completely ignored in binary unit commitment, even when explicitly set.

This happened because:

a) Constraint creation check only evaluated ramp_limit_up/ramp_limit_down, ignoring values of start_up/shut_down
b) The ramping constraint p[t] - p[t-1] <= ramp_up * p_nom * status[t-1] + ramp_start_up * p_nom * (status[t] - status[t-1]) was not applied for the first snapshot due to an overlap with the rolling horizon logic.

For more detail on binary UC formulation see these docs

Fix

  1. (optional) The fact that ramp_limit_up and ramp_limit_start_up have defaults NaN and 1 accordingly adds a touch of madness here. In this implementation, both defaults are aligned to NaN. Leave it to @lkstrp's moral compass.
  2. Constraint is now created if either ramp_limit_start_up/down or ramp_limit_up/down are explicitly set.
  3. New special constraint for the first snapshot for the non-rolling horizon case to set start-up limits

Other

  • Few RH tests fail, likely due to the new defaults. Can fix those or do revert defaults.
  • Treatment of ramp_limit_shut_down for the first snapshot

Reproduce

import pypsa
n = pypsa.Network()
snapshots = range(4)
n.set_snapshots(snapshots)

n.add("Bus", ["gas", "electricity"])
n.add("Generator", "gas", bus="gas", marginal_cost=10, p_nom=20000)

n.add(
    "Link",
    "OCGT",
    bus0="gas",
    bus1="electricity",
    committable=True,
    p_min_pu=0.1,
    efficiency=0.5,
    ramp_limit_start_up=0.4,  # This must have an effect
    # ramp_limit_up=0.2,
    start_up_cost=3333,
    p_nom=10000,
    up_time_before=0,
)

n.add(
    "Generator", "expensive_backstop", bus="electricity", p_nom=5000, marginal_cost=1e5
)
n.add("Load", "load", bus="electricity", p_set=[4000, 5000, 2000, 5000])

n.optimize()

print(n.c.links.dynamic.status)
print(n.c.links.dynamic.start_up)
print(-n.c.links.dynamic.p1)
print(n.c.generators.dynamic.p)

NB for testing ramp_limit_shut_down functionality that prevents immediate shutdown when the unit starts ON (eventually forcing production even with zero demand) you might want to add slack variable to trace correct behavior in otherwise infeasible problem:

n.add(
    "Generator",
    "slack_dump",
    bus="electricity",
    p_nom=20000,
    marginal_cost=1e6,
    sign=-1,
)

Checklist

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

@Irieo
Copy link
Copy Markdown
Contributor Author

Irieo commented Jan 28, 2026

The implementation ramp_limit_shut_down for the snapshot 0 appeared to be not an easy thing. I suggest implementation as here, so that unit test with such parametrisation as here 7424cd3 yields for print(n.c.generators.dynamic.p)

| snapshot | gas    | expensive_backstop | slack_dump |
|----------|--------|-------------------|------------|
| 0        | 9000.0 | 0.0               | 4500.0     |
| 1        | 10000.0| 0.0               | 0.0        |
| 2        | 1000.0 | 1500.0            | 0.0        |
| 3        | 0.0    | 0.0               | 0.0        |

This is not unambiguous, so @FabianHofmann check carefully if it follows your understanding.

@Irieo Irieo requested a review from FabianHofmann January 28, 2026 19:05
Copy link
Copy Markdown
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.

LGTM! Fine with the change in default. Thanks @Irieo for digging into it.

Copy link
Copy Markdown
Contributor

@FabianHofmann FabianHofmann left a comment

Choose a reason for hiding this comment

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

Thanks @Irieo. The fix is valuable. I have one implementation comment: the constraints are now very scattered.

This pr fixes the first snapshot for the non-rolling horizon case by deriving the initial status from up_time_before in an extra constraint. In the rolling horizon case this is already happening, there is a initial start status.

deal or no deal? : I would like to proposal how to integrate it into the rolling horizon constraints to have things more side by side. would you mind if I make a pr against yours? or create a separate one, to the see the effective diff? and if I don't do it this week, we pull this pr in

Comment on lines +601 to +603
if not c.committables.empty:
ramp_limit_start_up = c.da.ramp_limit_start_up
ramp_limit_shut_down = c.da.ramp_limit_shut_down
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.

we should select by com_i here too, right? otherwise all_null and ramp_limit_{start_up/shut_down} have different shapes, leading to broadcast nans

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right this is logically imprecise (should lock only relevant subset of components). I think combination of things that NaN is now a default (so applies for all non-committable) and that later all_null includes .all() that reduces the array to a single boolean made the overall logic to run through w/o errors

up_time_before = c.da.up_time_before.sel(name=com_i)
starts_from_on = up_time_before > 0
if starts_from_on.any():
# For first snapshot: p(0) >= (p_nom - ramp_limit_shut_down * p_nom)
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.

this assumes the component was dispatched at full capacity in the pre-optimization snapshot right? I am not sure if that is a good assumption. it is a bit arbitrary (the model forces you to be). I could imagine it is a likewise assumption that the dispatch was at p_min_pu and therefore it is feasible to shut down at the very first snapshot. So I am not sure if we should add this in the first place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Exactly. This indeed assumes a full capacity in the pre-optimization. I saw arguments for both p_nom and p_min_pu. I think the latter is less intuitive assumption, although it allows switching off units in the first snapshot for any value exceeding p_min_pu (so any reasonable value)

ramp_limit_down,static or series,per unit,NaN,"Maximum active power decrease from one snapshot to the next, per unit of the nominal power. Ignored if NaN. Does not consider snapshot weightings.",Input (optional)
ramp_limit_start_up,float,per unit,1,"Maximum active power increase at start up, per unit of the nominal power. Only used if `committable=True`.",Input (optional)
ramp_limit_shut_down,float,per unit,1,"Maximum active power decrease at shut down, per unit of the nominal power. Only used if `committable=True`.",Input (optional)
ramp_limit_start_up,float,per unit,NaN,"Maximum active power increase at start up, per unit of the nominal power. Only used if `committable=True`. Ignored if NaN.",Input (optional)
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.

this is a breaking change (not in pypsa functions, but possibly downstream). let's write that out in the release notes

Copy link
Copy Markdown
Contributor Author

@Irieo Irieo Feb 2, 2026

Choose a reason for hiding this comment

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

Right, it is mentioned in the release notes (I happen to push that simultaneously with the comment)
upd: I mean release notes mention change in default for the ramp_limit_start_up/down, w/o "breaking"

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.

This is a breaking change (not in pypsa functions, but possibly downstream). let's write that out in the release notes

@FabianHofmann
Is it though? Result will be the same, just some none constraining constraints are not going to be written anymore. Which is only a problem if you do custom stuff on top of them? At least that is my understanding and then I am also fine with the rename. Specially since this is first of all fixing a quite big bug. Otherwise my moral compass would probably go on

@Irieo
Copy link
Copy Markdown
Contributor Author

Irieo commented Feb 2, 2026

deal or no deal? : I would like to proposal how to integrate it into the rolling horizon constraints to have things more side by side. would you mind if I make a pr against yours? or create a separate one, to the see the effective diff? and if I don't do it this week, we pull this pr in

Full 🟢 for making it more compact. So Deal (w/ either of the two ways you suggest)

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.

4 participants