Negative Prices in Linearized Unit Commitment¶
This notebook shows how negative electricity prices can be reproduced with a linearized unit commitment (UC) model. Such prices appear in real markets when generators with start-up costs and minimum generation limits find it more economical to offer electricity at a negative price (effectively paying to stay online) rather than shutting down and restarting later.
Real-world context¶
Negative prices are a recurring feature of modern electricity markets. For example, such a situation occurred in Germany (e.g., Week 15 of 2025), when high renewable output combined with limited flexibility in conventional generation pushed spot prices below zero. This typically happens when:
- Wind and solar generation produce more power than demand in a given period
- Conventional generators, facing high start-up and shut-down costs, prefer to remain online even at negative prices
In this tutorial, we use PyPSA’s linearized unit commitment formulation to model and explore these dynamics in a simplified system.
Model setup¶
We model a single-bus system with two generators: one base-load and one peak-load, and a variable load over five time steps. The base-load unit has low marginal costs but high start-up costs and limited flexibility, while the peak unit is smaller, more expensive, and more flexible.
This setup allows negative prices to emerge during low-demand periods due to the trade-off between cycling costs and operating at minimum load.
Create the Network¶
import pandas as pd
import pypsa
n = pypsa.Network()
n.snapshots = range(5) # snapshots 0..4 (five periods)
# Add carrier definition
n.add("Carrier", "AC", color="lightblue")
# Add a single bus
n.add("Bus", "bus", carrier="AC")
# Add time-varying load with one low-demand valley (period index 3)
n.add("Load", "load", p_set=[50, 120, 50, 20, 50], bus="bus")
# Base-load generator: cheap marginal cost, inflexible, costly to cycle
n.add(
"Generator",
"base",
bus="bus",
p_nom=100,
marginal_cost=20,
p_min_pu=0.4,
committable=True, # Enable unit commitment
start_up_cost=4000,
shut_down_cost=2000,
)
# Peak generator: flexible but expensive
n.add(
"Generator",
"peak",
bus="bus",
p_nom=50,
marginal_cost=70,
p_min_pu=0.2,
committable=True, # Enable unit commitment
start_up_cost=250,
)
Optimize with linearized unit commitment¶
We enable the linearized UC constraints by setting linearized_unit_commitment=True in the optimize method:
n.optimize(linearized_unit_commitment=True)
This activates PyPSA’s linearized UC formulation, which relaxes binary commitment variables to continuous values in [0, 1]. Fractional values (e.g., 0.5) represent partial commitment in the relaxed model and make the problem convex.
Included effects:
- Start-up and shut-down costs
- Minimum stable generation (
p_min_pu) - Approximate commitment status and ramping constraints
Compared to the full mixed-integer formulation, the linearized version is more tractable and its dual variables (e.g. nodal prices) remain economically interpretable.
See the documentation: Linearized Unit Commitment.
n.optimize(
linearized_unit_commitment=True,
)
/tmp/ipykernel_8526/3783255132.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize(
WARNING:pypsa.optimization.constraints:The linear relaxation of the unit commitment cannot be tightened for all generators since the start up costs are not equal to the shut down costs. Proceed with the linear relaxation without the tightening by additional constraints for these. This might result in a longer solving time.
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.05s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 40 primals, 75 duals Objective: 7.90e+03 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p-fixed-upper, Generator-start_up-p-fixed-upper, Generator-shut_down-p-fixed-upper, Generator-com-p-lower, Generator-com-p-upper, Generator-com-transition-start-up, Generator-com-transition-shut-down, Generator-com-status-min_up_time_must_stay_up were not assigned to the network.
('ok', 'optimal')
Results Analysis¶
Let's examine the nodal prices (locational marginal prices, LMPs) at each time period. These prices represent the system's marginal cost of serving one additional MW of demand.
prices = n.buses_t.marginal_price
prices
| name | bus |
|---|---|
| snapshot | |
| 0 | 20.0 |
| 1 | 75.0 |
| 2 | 20.0 |
| 3 | -30.0 |
| 4 | 20.0 |
Note that during the low-demand snapshot, the model can produce a negative price. This indicates that the system would reduce its total cost if an additional MWh was consumed at that moment. This is a direct outcome of unit-commitment constraints and limited operational flexibility.
Let's look at the actual power output from each generator:
dispatch = n.generators_t.p
dispatch
| name | base | peak |
|---|---|---|
| snapshot | ||
| 0 | 50.0 | -0.0 |
| 1 | 100.0 | 20.0 |
| 2 | 50.0 | -0.0 |
| 3 | 20.0 | -0.0 |
| 4 | 50.0 | -0.0 |
The commitment status indicates whether a unit is online (1) or offline (0).
Note that mixed-integer UC formulation (with binary variables for start-up/down) is non-convex. Dual variables from a mixed-integer program are not strictly interpretable for the original problem. Any duals a solver reports pertain to the LP relaxation of the branch-and-bound nodes, not to the final integer solution, so they cannot be treated as market prices.
Linearized/relaxed UC replaces the binary commitment with a continuous variable in [0,1], yielding a convex LP. In this setting, strong duality holds and the dual of the power balance constraint is a well-defined shadow price (the marginal value of 1 MWh). The fractional commitment technique is a modeling workaround that provides consistent marginal cost signals and allows direct interpretation of dual variables.
status = n.generators_t.status
status
| name | base | peak |
|---|---|---|
| snapshot | ||
| 0 | 1.0 | 0.0 |
| 1 | 1.0 | 0.4 |
| 2 | 1.0 | 0.0 |
| 3 | 0.5 | 0.0 |
| 4 | 0.5 | 0.0 |
summary = pd.DataFrame(
{
"Load (MW)": n.loads_t.p_set["load"].values,
"Base Gen (MW)": n.generators_t.p["base"].values,
"Peak Gen (MW)": n.generators_t.p["peak"].values,
"Total Gen (MW)": n.generators_t.p.sum(axis=1).values,
"Base Status": n.generators_t.status["base"].values,
"Peak Status": n.generators_t.status["peak"].values,
"Price (€/MWh)": n.buses_t.marginal_price["bus"].values,
}
)
summary.index.name = "Time Period"
summary
| Load (MW) | Base Gen (MW) | Peak Gen (MW) | Total Gen (MW) | Base Status | Peak Status | Price (€/MWh) | |
|---|---|---|---|---|---|---|---|
| Time Period | |||||||
| 0 | 50.0 | 50.0 | -0.0 | 50.0 | 1.0 | 0.0 | 20.0 |
| 1 | 120.0 | 100.0 | 20.0 | 120.0 | 1.0 | 0.4 | 75.0 |
| 2 | 50.0 | 50.0 | -0.0 | 50.0 | 1.0 | 0.0 | 20.0 |
| 3 | 20.0 | 20.0 | -0.0 | 20.0 | 0.5 | 0.0 | -30.0 |
| 4 | 50.0 | 50.0 | -0.0 | 50.0 | 0.5 | 0.0 | 20.0 |
Understanding the negative price¶
At the low-demand period, the model produces a negative price. This occurs because keeping the base generator online is cheaper than cycling it off and on:
- Cycling cost: 6,000 € (4,000 € start-up + 2,000 € shut-down)
- Operational cost over the low-demand window: energy produced × marginal cost (with the parameters above: 120 MWh × 20 €/MWh = 2,400 €)
Since 2,400 € is lower than the 6,000 € cycling cost, the model finds it more economical to keep the base generator running through the low-demand period.
In market terms, this corresponds to a negative bid that the generator effectively offers to pay for staying online to avoid the higher cost of shutting down and restarting.
base = "base"
periods_low = [2, 3, 4]
su = float(n.generators.at[base, "start_up_cost"])
sd = float(n.generators.at[base, "shut_down_cost"])
cycle_cost = su + sd
mc = float(n.generators.at[base, "marginal_cost"])
gen_low = float(dispatch.loc[periods_low, base].sum()) # MWh over snapshots 2–4
op_cost = gen_low * mc
print("Why stay online?")
print("=" * 25)
print(f"Start-up cost: {su:,.0f} €")
print(f"Shut-down cost: {sd:,.0f} €")
print(f"Total cycling cost: {cycle_cost:,.0f} €\n")
print(f"Output (snapshots 2-4): {gen_low:.1f} MWh")
print(f"Operational cost: {op_cost:,.0f} €\n")
decision = "Stay online" if op_cost < cycle_cost else "Cycle off/on"
savings = abs(cycle_cost - op_cost)
print(f"Decision: {decision} is cheaper.")
print(f"Savings vs alternative: {savings:,.0f} €")
Why stay online? ========================= Start-up cost: 4,000 € Shut-down cost: 2,000 € Total cycling cost: 6,000 € Output (snapshots 2-4): 120.0 MWh Operational cost: 2,400 € Decision: Stay online is cheaper. Savings vs alternative: 3,600 €
Enumerating the -30 €/MWh (back-of-the-envelope)¶
To understand the -30 €/MWh price at snapshot 3, recall that the base generator faces high cycling costs (4,000 € start-up + 2,000 € shut-down = 6,000 € total). Turning it off at t = 3 and restarting at t = 4 would incur this full cost.
Instead, the optimizer keeps the unit partially online through the low-demand valley (snapshots 2–4), producing 50 + 20 + 50 = 120 MWh in total. By staying online, the system avoids the 6,000 € cycle, effectively spreading that saving across these 120 MWh:
$$ \frac{6{,}000\,\text{€}}{120\,\text{MWh}} = 50\,\text{€/MWh}. $$
With a variable generation cost of 20 €/MWh, the marginal price at t = 3 becomes:
$$ \text{LMP}_{t=3} = 20 - 50 = -30\,\text{€/MWh}. $$
Interpretation:
The system would save 30 € for each additional MWh consumed at t = 3,
which is precisely why the model reports a negative price