Skip to content

Optimization

The optimizer (optimizer.py) decides when and how much to charge or discharge the battery across multiple markets to maximize revenue.

Strategy Comparison

Rolling LP
~2-5s / year
Production default. Rolling foresight window with periodic re-optimization.
Full LP
~5-15s / year
Perfect foresight benchmark. Optimal but unrealistic — knows all future prices.
Sequential LP
~5-12s / year
Multi-stage optimization following real market gate-closure order. Most realistic.

Strategies

For a detailed guide with interactive examples, see Trading Strategies.

Rolling LP (rolling_lp) — Default

Solves a linear program over a rolling foresight window, executes near-term decisions, then rolls forward. Use the interactive diagram below to step through the rolling window process:

Window 1 / 9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Committed Execute window Foresight (solved, not committed) Future

Foresight: 5 days · Execute: 3 days · Current solve: days 1–5

ParameterDefaultEffect
foresight_days3How far ahead the optimizer can see (1-30)
execute_daysforesight_daysDays committed before re-solving

Lower execute_days = more responsive to changing conditions, slower execution.

A terminal SoC constraint is applied at the end of each window to prevent over-depletion. Degradation is tracked between windows by SimpleDegradationTracker, which updates the battery model every ~672 periods (~7 days).

Typical execution: ~2-5 seconds for 1 year.

Full LP (lp) — Perfect Foresight

Solves a single LP over the entire simulation period with complete knowledge of all future prices.

  • Provides the theoretical maximum revenue (optimal solution)
  • Not realistic — assumes perfect price knowledge
  • Higher memory usage and solve time (~5-15 seconds for 1 year)
  • Useful as a benchmark to evaluate the rolling strategy

Sequential LP (sequential_lp) — Market-Aware

Solves separate LPs per market stage in real-world gate-closure order. Between stages, previously committed variables are frozen (lower bound = upper bound = decided value).

Stage 1: Capacity Auctions
~1-2 days before delivery
Optimizing
aFRR
Optimizing
FCR
Pending
Day-Ahead
Pending
Intraday
Pending
Imbalance
Capacity markets clear first. The optimizer commits MW bids for aFRR and FCR, reserving battery headroom.
1
2
3
4
Optimizing Frozen (committed) Pending

Stages (in gate-closure order):

  1. Capacity — aFRR + FCR (~1-2 days before delivery)
  2. Day-Ahead — DA energy (~12 hours before delivery)
  3. Intraday — ID energy (~30 min before delivery)
  4. Imbalance — Real-time settlement

Uses the same rolling window and parameter framework as rolling_lp. Variables for future stages are zeroed during earlier stages to prevent free-energy exploitation.

Typical execution: ~5-12 seconds for 1 year (~2-3x slower than rolling_lp).

LP Formulation

Decision Variables

All variables are per 15-minute period t ∈ [0, T). Energy variables are in MWh, capacity variables in MW.

Energy trading (only created for enabled markets):

VariableMarketDescription
da_charge[t], da_discharge[t]Day-AheadDA charge/discharge energy
id_charge[t], id_discharge[t]IntradayID charge/discharge energy
imb_charge[t], imb_discharge[t]ImbalanceImbalance charge/discharge energy

Capacity reservation:

VariableMarketDescription
afrr_up[t], afrr_down[t]aFRRUpward/downward capacity (MW)
fcr_capacity[t]FCRSymmetric capacity (MW)
afrr_up_block[b], afrr_down_block[b]aFRRBlock-level commitments
fcr_block_capacity[b]FCRBlock-level commitments

State:

VariableBoundsCount
soc[t][min_soc_mwh, max_soc_mwh]T + 1

Typical Problem Size

ConfigurationVariablesConstraintsSolve Time
7-day window, DA only~2,000~2,700~25 ms
7-day window, all markets~7,000~10,000~60 ms
1-year rolling (52 windows)~3 seconds

Objective Function

Maximize total revenue across all enabled markets:

max Σ_t [
  (da_discharge[t] − da_charge[t]) × da_price[t]
+ (id_discharge[t] − id_charge[t]) × id_price[t]
+ imb_discharge[t] × imb_long_price[t]
− imb_charge[t]    × imb_short_price[t]
+ afrr_up[t]   × afrr_up_price[t]   × Δt × 1.51
+ afrr_down[t] × afrr_down_price[t] × Δt × 1.51
+ fcr_capacity[t] × fcr_price[t] × Δt
]

Where:

  • Δt = 0.25 hours (15-minute periods)
  • aFRR multiplier 1.51 = capacity revenue (1.0) + expected energy revenue (0.51)
  • Terms for disabled markets are absent, not zeroed

Constraints

Energy Balance (SoC Dynamics)

soc[t+1] = soc[t] + total_charge[t] × √RTE − total_discharge[t] / √RTE

RTE is split symmetrically as √RTE to avoid double-counting losses.

Grid Power Limits

total_charge[t]    ≤ charge_limit(t) × Δt
total_discharge[t] ≤ grid_discharge_limit × Δt

Where charge_limit(t) = min(base_grid_limit, monthly_limit, hourly_limit).

Battery Power with Capacity Reservation

total_charge[t]    + (afrr_down[t] + fcr[t]) × Δt ≤ P_batt × Δt
total_discharge[t] + (afrr_up[t]   + fcr[t]) × Δt ≤ P_batt × Δt

Capacity Allocation

afrr_up[t]      ≤ P_batt × max_afrr_allocation
afrr_down[t]    ≤ P_batt × max_afrr_allocation
fcr_capacity[t] ≤ P_batt × max_fcr_allocation

Block Commitment

fcr_capacity[t] = fcr_block[t ÷ 16]          (4-hour blocks)
afrr_up[t]      = afrr_up_block[t ÷ B]       (B = block_hours / Δt)
afrr_down[t]    = afrr_down_block[t ÷ B]

SoC Headroom for Capacity Delivery

soc[t] ≥ min_soc + (fcr[t] + afrr_up[t]) × 0.25
soc[t] ≤ max_soc − (fcr[t] + afrr_down[t]) × 0.25

Imbalance Signal Constraints

if regulation_state[t] ∈ {0, 2}: imb_charge[t] = imb_discharge[t] = 0

Daily Cycle Limit (optional)

Σ_{t∈day} (total_charge[t] + total_discharge[t]) ≤ max_cycles_per_day × 2 × C_batt

Ramp Rate (optional)

|net_power[t] − net_power[t−1]| ≤ ramp_limit

Linearized as two one-sided constraints.

Solver Stack

Per-window overhead:

PhaseTime
PuLP model construction~30-40 ms
File I/O (.mps write/read)~5-10 ms
HiGHS solve (simplex)~15-25 ms
Result extraction~5 ms
Total~60 ms

For 1-year rolling with 52 windows: ~3 seconds total.

Price Scaling

An optional feature for modeling conservative or stressed market scenarios.

Energy markets (day-ahead, intraday, imbalance) — percentile clipping:

  • Clips the top and bottom N% of prices
  • Example: 5.0 clips prices below the 5th and above the 95th percentile

Capacity markets (aFRR, FCR) — multiplier scaling:

  • Scales all prices by a factor
  • Example: 0.8 reduces all capacity prices by 20%

Per-market overrides take precedence over the global price_scaling_percentile setting.

Capacity Allocation

Controls how much battery power is available for capacity markets vs energy trading:

  • max_capacity_allocation: 0.5 → at most 50% of power for aFRR + FCR combined
  • max_afrr_allocation: 0.3 → at most 30% for aFRR specifically
  • max_fcr_allocation: 0.2 → at most 20% for FCR specifically

Remaining power is available for day-ahead, intraday, and imbalance trading.