Subsidies, CfDs and PPAs as Post-Processing¶
Support schemes such as feed-in premiums, Contracts for Difference (CfDs) and Power Purchase Agreements (PPAs) rarely change the dispatch of an asset: they redistribute the revenue that the asset earns once the market has cleared. That makes them a natural fit for a post-processing step on a solved network, rather than a new term in the optimisation objective.
This example shows how to settle a renewable generator under several real-world schemes
using only the dispatch (n.generators_t.p) and the locational marginal price
(n.buses_t.marginal_price) from a standard linear optimal power flow. No custom
constraints or extra variables are required.
The schemes covered:
- Two-sided CfD (UK / EU style): symmetric top-up and clawback around a strike price.
- One-sided feed-in premium with a price floor (Dutch SDE++ style): subsidy only, no clawback.
- Cap-and-floor: revenue stabilised within a price band.
- Virtual PPA: a fixed-price financial swap, contrasted with a baseload PPA.
- Advanced variant: the UK negative-price rule, which pauses CfD payments during sustained negative prices, to show how scheme-specific tweaks plug in.
Why post-processing? Explicitly modelling a CfD inside the LP would couple revenue to the endogenous price $\lambda_t$, which only exists after the problem is solved. The standard workaround (used here) is to solve a normal LOPF, read out the prices, and settle the contract as an exogenous cash flow. See PyPSA issue #1475 for the discussion.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
A Minimal Market¶
We build a single bidding zone over one week (168 hourly snapshots). Demand follows a
diurnal pattern, and the merit order is set by a small wind fleet plus a three-step
thermal ladder (base, mid, peak). The wind generator is the asset whose support
contract we will settle.
To make the settlement interesting we engineer two spells into the week:
- A storm (hours 30-41): a stormy, low-demand night where abundant wind is curtailed and sets a negative price (it bids slightly below zero, as subsidised renewables do in practice). This is the spell that later triggers the UK negative-price rule.
- A wind lull at high demand (hours 108-115): wind almost vanishes while load peaks,
so the
peakplant runs and sets a scarcity price well above the strike and the cap, triggering clawback under the two-sided and cap-and-floor schemes.
n_hours = 168
snapshots = pd.date_range("2030-01-01", periods=n_hours, freq="h")
hour = np.arange(n_hours)
# Diurnal demand: night trough ~160 MW, evening peak ~440 MW.
load = pd.Series(300 + 140 * np.sin(2 * np.pi * (hour - 9) / 24), index=snapshots)
# Wind capacity factor: a weekly swell with a diurnal ripple.
cf = np.clip(
0.45 + 0.40 * np.sin(2 * np.pi * hour / 47) + 0.12 * np.sin(2 * np.pi * hour / 9),
0.02,
1.0,
)
cf = pd.Series(cf, index=snapshots)
# Storm (hours 30-41): abundant wind meets low load -> curtailment, negative prices.
storm = slice(30, 42)
cf.iloc[storm] = 1.0
load.iloc[storm] = 160.0
# Wind lull at high demand (hours 108-115): wind vanishes, the peak plant sets scarcity.
scarcity = slice(108, 116)
cf.iloc[scarcity] = 0.03
load.iloc[scarcity] = 460.0
P_NOM_WIND = 300.0
n = pypsa.Network()
n.set_snapshots(snapshots)
n.add("Carrier", "AC")
n.add("Bus", "market")
# Wind bids slightly negative (subsidised feed-in), so it sets a negative price when curtailed.
n.add(
"Generator",
"wind",
bus="market",
carrier="wind",
p_nom=P_NOM_WIND,
marginal_cost=-3.0,
p_max_pu=cf,
)
# Thermal merit-order ladder: base load, mid merit, and a peaker for scarcity hours.
n.add("Generator", "base", bus="market", carrier="base", p_nom=200, marginal_cost=30.0)
n.add("Generator", "mid", bus="market", carrier="mid", p_nom=150, marginal_cost=70.0)
n.add("Generator", "peak", bus="market", carrier="peak", p_nom=250, marginal_cost=180.0)
n.add("Load", "demand", bus="market", p_set=load, carrier="load")
n.add(
"Carrier",
["wind", "base", "mid", "peak", "load"],
color=["#4c9f70", "#8c8c8c", "#f4a261", "#e63946", "#264653"],
)
n.sanitize()
n.optimize(solver_name="highs", output_flag=False)
/home/runner/work/PyPSA/PyPSA/pypsa/network/io.py:2079: FutureWarning: pandas infers the `str` dtype for string data since its version 3.0. PyPSA still converts it back to numpy object dtype on import, but will keep it from PyPSA 2.0 on. Set `pypsa.options.api.legacy_string_dtype` explicitly to suppress this warning. new_static = _coerce_string_dtypes(new_static) INFO:pypsa.consistency:Sanitizing network...
INFO:pypsa.components._types.carriers:Assigned colors to 1 carriers using 'tab10' palette.
INFO:pypsa.consistency:Network sanitization complete.
/tmp/ipykernel_2489/2825684526.py:34: 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(solver_name="highs", output_flag=False)
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - output_flag: False - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 672 primals, 1512 duals Objective: 1.07e+06 Solver: highs Runtime: 0.00s MIP gap: inf Dual bound: 0.00e+00 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
n.stats.energy_balance.iplot()
Reading Out Price and Dispatch¶
The two ingredients for any settlement are the market price the asset sees (here the
locational marginal price at its bus) and its dispatched energy. The headline figures
come straight from n.statistics: supply for the generated energy, revenue for the
market revenue, and market_value for the capture price. These already apply the snapshot
weightings, so they stay correct for sub-hourly or otherwise weighted snapshots.
The settlement itself works per snapshot, so we also read out the raw price and dispatch
time series and the weightings (n.snapshot_weightings.generators), which the top-up
recipes below combine explicitly.
price = n.buses_t.marginal_price["market"] # EUR/MWh, per snapshot
dispatch = n.generators_t.p["wind"] # MW, per snapshot
weights = n.snapshot_weightings.generators # h, per snapshot
total_mwh = n.statistics.supply().loc["Generator", "wind"]
market_revenue = n.statistics.revenue().loc["Generator", "wind"]
capture_price = n.statistics.market_value().loc["Generator", "wind"]
print(f"Wind energy: {total_mwh / 1e3:7.1f} GWh")
print(f"Capture price: {capture_price:7.1f} EUR/MWh")
print(f"Hours with price < 0: {(price < 0).sum():7d}")
print(f"Price range: {price.min():.1f} to {price.max():.1f} EUR/MWh")
Wind energy: 23.5 GWh Capture price: 29.3 EUR/MWh Hours with price < 0: 36 Price range: -3.0 to 180.0 EUR/MWh
The capture price (revenue divided by generation) is below the time-average price because wind earns less in the hours when it produces most: the classic cannibalisation effect. Support schemes exist precisely to insulate the investor from this and from price volatility.
A Quick Look at the Cleared Market¶
Before settling anything, it helps to see what the solve produced. The top panel is the market price; the bottom panel stacks the dispatch against demand. The storm spell (shaded blue) is where wind is curtailed and the price turns negative; the wind lull (shaded red) is where the peak plant runs and the price spikes.
fig, (axp, axd) = plt.subplots(
2, 1, figsize=(10, 6), sharex=True, gridspec_kw={"height_ratios": [2, 3]}
)
axp.step(hour, price.to_numpy(), where="post", color="C0")
axp.axhline(0, color="grey", lw=0.8)
axp.set_ylabel("Price [EUR/MWh]")
axp.set_title("Cleared market price and dispatch")
gen = n.generators_t.p[["wind", "base", "mid", "peak"]]
axd.stackplot(hour, gen.to_numpy().T, labels=gen.columns, alpha=0.85)
axd.plot(hour, load.to_numpy(), color="k", lw=1.0, label="load")
axd.set_xlabel("Hour of the week")
axd.set_ylabel("Dispatch [MW]")
axd.legend(loc="upper left", ncol=5, fontsize=8)
for ax in (axp, axd):
ax.axvspan(storm.start, storm.stop - 1, color="C0", alpha=0.10)
ax.axvspan(scarcity.start, scarcity.stop - 1, color="C3", alpha=0.10)
plt.tight_layout()
plt.show()
A Tiny Settlement Helper¶
Every scheme below boils down to a per-snapshot top-up rate $r_t$ in EUR/MWh applied to the dispatched energy. The cash flow to the generator in each snapshot is
$$ \text{cashflow}_t = r_t \cdot g_t \cdot w_t $$
where $g_t$ is the dispatch (MW) and $w_t$ the snapshot weighting (h). A positive cash flow means the generator receives money; a negative one means it pays back (clawback). Only the definition of $r_t$ changes between schemes.
def settle(
top_up_rate: pd.Series, dispatch: pd.Series, weights: pd.Series
) -> pd.Series:
"""Per-snapshot cash flow (EUR) to the generator for a given top-up rate (EUR/MWh)."""
return top_up_rate * dispatch * weights
def summarise(name: str, cashflow: pd.Series) -> dict:
"""Headline figures for a settled scheme."""
subsidy = cashflow.clip(lower=0).sum()
clawback = abs(cashflow.clip(upper=0).sum())
net = cashflow.sum()
achieved = (market_revenue + net) / total_mwh
return {
"scheme": name,
"subsidy_keur": subsidy / 1e3,
"clawback_keur": clawback / 1e3,
"net_support_keur": net / 1e3,
"achieved_price": achieved,
"subsidy_hours": float((cashflow > 0).mul(weights).sum()),
"clawback_hours": float((cashflow < 0).mul(weights).sum()),
}
results = {}
1. Two-Sided CfD¶
The Contract for Difference settles the gap between a fixed strike price $S$ and the market price for every MWh produced:
$$ r_t = S - \lambda_t $$
When the market price is below the strike, the generator is topped up; when it is above, the generator pays the difference back. Over time the asset earns close to a flat $S$ per MWh regardless of the market, which is exactly what de-risks the investment.
STRIKE = 65.0 # EUR/MWh
cfd_two_sided = settle(STRIKE - price, dispatch, weights)
results["1. Two-sided CfD"] = summarise("1. Two-sided CfD", cfd_two_sided)
pd.Series(results["1. Two-sided CfD"]).to_frame("value")
| value | |
|---|---|
| scheme | 1. Two-sided CfD |
| subsidy_keur | 897.372479 |
| clawback_keur | 60.285318 |
| net_support_keur | 837.087161 |
| achieved_price | 65.0 |
| subsidy_hours | 119.0 |
| clawback_hours | 49.0 |
2. One-Sided Feed-in Premium with a Price Floor (SDE++ style)¶
The Dutch SDE++ scheme is a one-sided subsidy: the state tops the producer up to the
strike (the basisbedrag) but never claws back when prices are high. Crucially it also
applies a price floor: the subsidy is computed against max(price, floor), so once
the market price drops below the floor (and especially when it goes negative) the
top-up stops growing. This deliberately exposes the producer to deep negative-price
hours, discouraging production into a saturated market.
$$ r_t = \max\bigl(S - \max(\lambda_t,\ \text{floor}),\ 0\bigr) $$
FLOOR = 0.0 # EUR/MWh: subsidy frozen at/below this market price
premium_rate = np.maximum(STRIKE - price.clip(lower=FLOOR), 0.0)
fip_one_sided = settle(premium_rate, dispatch, weights)
results["2. One-sided premium"] = summarise("2. One-sided premium", fip_one_sided)
pd.Series(results["2. One-sided premium"]).to_frame("value")
| value | |
|---|---|
| scheme | 2. One-sided premium |
| subsidy_keur | 878.540032 |
| clawback_keur | 0.0 |
| net_support_keur | 878.540032 |
| achieved_price | 66.766834 |
| subsidy_hours | 119.0 |
| clawback_hours | 0.0 |
Note the absence of clawback hours, and that the net support is larger than the two-sided CfD: there is no payback in the scarcity hours. The price floor caps how much the scheme pays in the cheapest hours.
3. Cap-and-Floor¶
A cap-and-floor scheme (used e.g. for interconnectors and some merchant assets) stabilises revenue within a band. The producer keeps its market revenue, but is topped up when the price falls below a floor $F$ and pays back when it rises above a cap $C$:
$$ r_t = \max(F - \lambda_t,\ 0) - \max(\lambda_t - C,\ 0) $$
Inside the band $[F, C]$ the producer is fully exposed to the market.
Real cap-and-floor regimes (GB interconnectors, long-duration storage) usually assess cumulative revenue over multi-year periods and true up only the shortfall or excess at the end of each period. Here we apply the band to the hourly price instead, as a transparent per-snapshot analogue that keeps the recipe identical to the other schemes.
PRICE_FLOOR, PRICE_CAP = 40.0, 120.0
caf_rate = np.maximum(PRICE_FLOOR - price, 0.0) - np.maximum(price - PRICE_CAP, 0.0)
cap_and_floor = settle(caf_rate, dispatch, weights)
results["3. Cap-and-floor"] = summarise("3. Cap-and-floor", cap_and_floor)
pd.Series(results["3. Cap-and-floor"]).to_frame("value")
| value | |
|---|---|
| scheme | 3. Cap-and-floor |
| subsidy_keur | 404.361361 |
| clawback_keur | 22.679557 |
| net_support_keur | 381.681804 |
| achieved_price | 45.589389 |
| subsidy_hours | 119.0 |
| clawback_hours | 20.0 |
4. Virtual PPA vs Baseload PPA¶
A virtual (financial) PPA is a fixed-price swap on the asset's generated volume, so it is a pay-as-produced contract. At a contract price $P$ the off-taker pays the generator $P$ per MWh and keeps the spot revenue, so the generator's net contract cash flow is identical in shape to a two-sided CfD with strike $P$:
$$ r^{\text{gen}}_t = P - \lambda_t \qquad r^{\text{buyer}}_t = \lambda_t - P $$
The two parties' cash flows are mirror images and sum to zero.
PPA_PRICE = 60.0 # EUR/MWh
vppa_generator = settle(PPA_PRICE - price, dispatch, weights)
vppa_buyer = settle(price - PPA_PRICE, dispatch, weights)
results["4a. vPPA (generator)"] = summarise("4a. vPPA (generator)", vppa_generator)
print(f"vPPA generator net: {vppa_generator.sum() / 1e3:8.1f} kEUR")
print(f"vPPA buyer net: {vppa_buyer.sum() / 1e3:8.1f} kEUR")
print(f"Sum (should be ~0): {(vppa_generator + vppa_buyer).sum():8.2e} EUR")
vPPA generator net: 719.8 kEUR vPPA buyer net: -719.8 kEUR Sum (should be ~0): 0.00e+00 EUR
A baseload PPA instead fixes a constant volume $Q$ every hour, independent of when the asset generates. This changes who carries the shape (profile) risk.
Under the pay-as-produced virtual PPA above, the buyer takes delivery of the asset's wind-shaped volume: it pays a fixed price for energy that clusters in low-price hours, so the profile risk sits with the buyer. A baseload PPA hands the buyer a firm, flat block instead, and pushes that risk onto the seller, who must now firm its variable output up to the block, buying the shortfall (and selling the surplus) on the spot market.
The two are mirror images: the buyer's profile cost under the vPPA equals the seller's firming cost under the baseload PPA, same magnitude, opposite party. Both are just the gap between the value of the asset's shaped volume and a flat block at the same average price, which is the cannibalisation effect seen earlier.
mean_price = (price * weights).sum() / weights.sum()
Q_BASELOAD = (
total_mwh / weights.sum()
) # MW: flat block sized to the asset's average output
# vPPA (pay-as-produced): the buyer pays a fixed price for shaped volume worth less than
# a flat block at the same average price, so it absorbs the profile cost.
buyer_profile_cost = mean_price * total_mwh - market_revenue
# Baseload PPA: the seller delivers a flat block and firms its output, buying the
# shortfall / selling the surplus on the spot market. Its firming cost is the mirror image.
seller_firming_cost = (price * (Q_BASELOAD - dispatch) * weights).sum()
print(f"Flat baseload block: {Q_BASELOAD:6.1f} MW")
print(f"Buyer profile cost (vPPA): {buyer_profile_cost / 1e3:8.1f} kEUR")
print(f"Seller firming cost (baseload): {seller_firming_cost / 1e3:8.1f} kEUR")
Flat baseload block: 139.7 MW Buyer profile cost (vPPA): 431.0 kEUR Seller firming cost (baseload): 431.0 kEUR
5. Advanced: the UK Negative-Price Rule¶
Recent UK CfD allocation rounds withhold payments during sustained negative prices: no top-up is paid for any hour belonging to a run of (here) 6 or more consecutive negative-price hours. This discourages renewables from running when the system is already oversupplied. It is a good illustration of how a scheme-specific rule slots into the post-processing step as a simple boolean mask.
NEG_RUN_HOURS = 6
is_negative = price < 0
run_id = (is_negative != is_negative.shift()).cumsum() # contiguous-run labels
run_length = is_negative.groupby(run_id).transform("size") # length of each run
excluded = is_negative & (run_length >= NEG_RUN_HOURS) # hours where payment pauses
cfd_neg_rule = cfd_two_sided.where(~excluded, 0.0)
results["5. Two-sided CfD + neg-rule"] = summarise(
"5. Two-sided CfD + neg-rule", cfd_neg_rule
)
print(f"Negative-price hours: {is_negative.sum():4d}")
print(f"Hours in runs >= {NEG_RUN_HOURS}h (payment paused): {int(excluded.sum()):4d}")
print(
f"Top-up withheld vs plain CfD: "
f"{(cfd_two_sided[excluded].sum()) / 1e3:8.1f} kEUR"
)
Negative-price hours: 36 Hours in runs >= 6h (payment paused): 32 Top-up withheld vs plain CfD: 381.4 kEUR
Comparison Across Schemes¶
Pulling the headline figures together. achieved_price is the asset's effective
realised price (market capture price plus net support, per MWh); the schemes differ
mainly in how much risk they leave with the producer versus the counterparty.
comparison = pd.DataFrame(results.values()).set_index("scheme").round(1)
comparison
| subsidy_keur | clawback_keur | net_support_keur | achieved_price | subsidy_hours | clawback_hours | |
|---|---|---|---|---|---|---|
| scheme | ||||||
| 1. Two-sided CfD | 897.4 | 60.3 | 837.1 | 65.0 | 119.0 | 49.0 |
| 2. One-sided premium | 878.5 | 0.0 | 878.5 | 66.8 | 119.0 | 0.0 |
| 3. Cap-and-floor | 404.4 | 22.7 | 381.7 | 45.6 | 119.0 | 20.0 |
| 4a. vPPA (generator) | 798.8 | 79.0 | 719.8 | 60.0 | 119.0 | 49.0 |
| 5. Two-sided CfD + neg-rule | 515.9 | 60.3 | 455.7 | 48.7 | 87.0 | 49.0 |
Visualising the Settlement¶
The first plot shows the market price against the CfD strike over the first three days, with the negative-price run that triggers the UK rule shaded. The second compares the net support and the achieved price across all schemes.
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(10, 8), gridspec_kw={"height_ratios": [3, 2]}
)
window = slice(0, 72)
h = hour[window]
p = price.iloc[window].to_numpy()
d = dispatch.iloc[window].to_numpy()
ax1.step(h, p, where="post", color="C0", label="Market price")
ax1.axhline(STRIKE, color="C3", ls="--", label=f"CfD strike ({STRIKE:.0f})")
ax1.axhline(0, color="grey", lw=0.8)
ax1.fill_between(
h, 0, p, where=(p < 0), step="post", color="C0", alpha=0.25, label="Negative prices"
)
ax1b = ax1.twinx()
ax1b.fill_between(h, 0, d, step="post", color="C2", alpha=0.15)
ax1b.set_ylabel("Wind dispatch [MW]", color="C2")
ax1b.tick_params(axis="y", labelcolor="C2")
ax1.set_xlabel("Hour of the week")
ax1.set_ylabel("Price [EUR/MWh]")
ax1.set_title("Market price vs CfD strike (first 3 days)")
ax1.legend(loc="upper left", fontsize=8)
net = comparison["net_support_keur"]
colors = ["C3" if v < 0 else "C0" for v in net]
ax2.bar(range(len(net)), net.values, color=colors, alpha=0.8)
ax2.set_xticks(range(len(net)))
ax2.set_xticklabels(comparison.index, rotation=20, ha="right", fontsize=8)
ax2.axhline(0, color="grey", lw=0.8)
ax2.set_ylabel("Net support [kEUR]")
ax2.set_title("Net support to the generator by scheme")
plt.tight_layout()
plt.show()
Takeaways¶
- CfDs, feed-in premiums and PPAs can all be settled as a post-processing step on a
solved network: read
n.buses_t.marginal_priceandn.generators_t.p, define a per-snapshot top-up rate, and weight byn.snapshot_weightings.generators. - The schemes share one line of arithmetic; what differs is whether they claw back (two-sided CfD, cap-and-floor), apply a floor (SDE++), settle a fixed volume (baseload PPA), or mask out specific hours (the negative-price rule).
- Because the contract does not enter the LP, the dispatch is unchanged. This is the standard approximation; modelling the price feedback explicitly would require a bilevel / MPEC formulation, which is out of scope here.
These recipes are deliberately small and self-contained so they can be adapted to a specific scheme. See issue #1475 for the wider discussion on turning post-processing steps like these into reusable plugins.