Batteries don't just arbitrage energy prices. They can also provide grid balancing services — and the revenues are often higher and more stable than spot arbitrage alone. This lesson covers the three main frequency response products and how to include them in your optimizer.
The grid frequency must stay at 50.000 Hz (in Europe). When generation ≠ demand, frequency deviates. TSOs (Transmission System Operators) procure reserve capacity from assets like batteries to restore balance. You get paid for keeping capacity available — even if you never actually deliver energy.
To provide symmetric FCR (±X MW), your battery must be able to both increase and decrease output at any moment. This means maintaining SoC near 50% — if you're empty, you can't discharge; if full, you can't charge.
# FCR reservation: ±5 MW for a 4-hour block
# Battery must keep enough headroom in both directions
fcr_power = 5.0 # MW reserved for FCR
fcr_duration = 0.25 # hours (15 min sustain requirement)
fcr_energy = fcr_power * fcr_duration # 1.25 MWh headroom each direction
# SoC must stay within these bounds during FCR provision:
# SoC ≥ SoC_min + fcr_energy (can still discharge if called)
# SoC ≤ SoC_max - fcr_energy (can still charge if called)
The key insight: you can provide FCR and do arbitrage simultaneously, as long as you respect the capacity reservation. The optimizer decides how to split the battery's power between markets:
"""
cooptimization.py — Joint day-ahead arbitrage + FCR bidding
"""
import pyomo.environ as pyo
import numpy as np
T = 24
prices = [28, 25, 22, 20, 19, 21, 35, 55,
62, 58, 52, 48, 45, 44, 47, 53,
68, 85, 92, 78, 60, 45, 35, 30]
# FCR auction results: capacity price per 4-hour block (€/MW/h)
# Blocks: 0-3, 4-7, 8-11, 12-15, 16-19, 20-23
fcr_capacity_prices = [3.5, 4.0, 5.5, 6.0, 12.0, 4.5] # €/MW/h
P_max = 10.0
E_max = 20.0
eta = 0.90
eta_sqrt = np.sqrt(eta)
soc_init = 10.0
SoC_min = 0.1 * E_max
SoC_max = 0.9 * E_max
deg_cost = 5.0
fcr_duration = 0.25 # 15-min sustain
model = pyo.ConcreteModel()
model.T = pyo.RangeSet(0, T - 1)
model.B = pyo.RangeSet(0, 5) # 6 FCR blocks
# Decision variables
model.p_charge = pyo.Var(model.T, bounds=(0, None))
model.p_discharge = pyo.Var(model.T, bounds=(0, None))
model.soc = pyo.Var(model.T, bounds=(SoC_min, SoC_max))
model.y = pyo.Var(model.T, within=pyo.Binary)
model.fcr = pyo.Var(model.B, bounds=(0, P_max)) # MW reserved for FCR per block
# Map hours to FCR blocks
def block_of(t):
return t // 4
# Power limits: arbitrage power + FCR reservation ≤ P_max
def power_limit_charge(m, t):
b = block_of(t)
return m.p_charge[t] + m.fcr[b] <= P_max
def power_limit_discharge(m, t):
b = block_of(t)
return m.p_discharge[t] + m.fcr[b] <= P_max
model.pl_c = pyo.Constraint(model.T, rule=power_limit_charge)
model.pl_d = pyo.Constraint(model.T, rule=power_limit_discharge)
# SoC must have headroom for FCR activation
def soc_fcr_lower(m, t):
b = block_of(t)
return m.soc[t] >= SoC_min + m.fcr[b] * fcr_duration
def soc_fcr_upper(m, t):
b = block_of(t)
return m.soc[t] <= SoC_max - m.fcr[b] * fcr_duration
model.soc_fcr_lo = pyo.Constraint(model.T, rule=soc_fcr_lower)
model.soc_fcr_hi = pyo.Constraint(model.T, rule=soc_fcr_upper)
# SoC balance (same as before)
def soc_rule(m, t):
prev = soc_init if t == 0 else m.soc[t-1]
return m.soc[t] == prev + eta_sqrt * m.p_charge[t] - m.p_discharge[t] / eta_sqrt
model.soc_bal = pyo.Constraint(model.T, rule=soc_rule)
# No simultaneous charge/discharge
def nc(m, t): return m.p_charge[t] <= P_max * (1 - m.y[t])
def nd(m, t): return m.p_discharge[t] <= P_max * m.y[t]
model.nc = pyo.Constraint(model.T, rule=nc)
model.nd = pyo.Constraint(model.T, rule=nd)
# Objective: arbitrage revenue + FCR capacity revenue - degradation
def obj_rule(m):
arb_revenue = sum(prices[t] * (m.p_discharge[t] - m.p_charge[t]) for t in m.T)
fcr_revenue = sum(m.fcr[b] * fcr_capacity_prices[b] * 4 for b in m.B) # 4 hours per block
degradation = sum(deg_cost * (m.p_charge[t] + m.p_discharge[t]) for t in m.T)
return arb_revenue + fcr_revenue - degradation
model.obj = pyo.Objective(rule=obj_rule, sense=pyo.maximize)
# Solve
solver = pyo.SolverFactory('highs')
result = solver.solve(model, tee=False)
print(f"Total revenue: €{pyo.value(model.obj):.2f}")
print(f" Arbitrage: €{sum(prices[t] * (pyo.value(model.p_discharge[t]) - pyo.value(model.p_charge[t])) for t in range(T)):.2f}")
print(f" FCR: €{sum(pyo.value(model.fcr[b]) * fcr_capacity_prices[b] * 4 for b in range(6)):.2f}")
print(f"\nFCR reservations per block:")
for b in range(6):
print(f" Block {b} (hours {b*4}-{b*4+3}): {pyo.value(model.fcr[b]):.1f} MW")
Reserving power for FCR reduces the power available for arbitrage. The optimizer balances:
Unlike FCR (mostly capacity), aFRR and mFRR also pay for delivered energy when activated. The optimizer must account for:
Modeling this properly requires stochastic optimization — which we'll cover in Lesson 7. For now, the deterministic approach: estimate expected activation hours and include expected energy revenue.
A 10 MW battery reserves 6 MW for FCR. How much power is available for day-ahead arbitrage?
Why does the optimizer add SoC headroom constraints (SoC ≥ SoC_min + fcr_energy) during FCR provision?
On a day with very low day-ahead price spreads but high FCR capacity prices, what should the optimizer do?
ENTSO-E — Electricity Balancing — the European framework for balancing markets (FCR, aFRR, mFRR). Read the "Implementation" section for PICASSO (aFRR) and MARI (mFRR) platform rules.
Rechtliches: Impressum