Optimization
The optimizer (optimizer.py) decides when and how much to charge or discharge the battery across multiple markets to maximize revenue.
Strategy Comparison
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:
Foresight: 5 days · Execute: 3 days · Current solve: days 1–5
| Parameter | Default | Effect |
|---|---|---|
foresight_days | 3 | How far ahead the optimizer can see (1-30) |
execute_days | foresight_days | Days 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).
Stages (in gate-closure order):
- Capacity — aFRR + FCR (~1-2 days before delivery)
- Day-Ahead — DA energy (~12 hours before delivery)
- Intraday — ID energy (~30 min before delivery)
- 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):
| Variable | Market | Description |
|---|---|---|
da_charge[t], da_discharge[t] | Day-Ahead | DA charge/discharge energy |
id_charge[t], id_discharge[t] | Intraday | ID charge/discharge energy |
imb_charge[t], imb_discharge[t] | Imbalance | Imbalance charge/discharge energy |
Capacity reservation:
| Variable | Market | Description |
|---|---|---|
afrr_up[t], afrr_down[t] | aFRR | Upward/downward capacity (MW) |
fcr_capacity[t] | FCR | Symmetric capacity (MW) |
afrr_up_block[b], afrr_down_block[b] | aFRR | Block-level commitments |
fcr_block_capacity[b] | FCR | Block-level commitments |
State:
| Variable | Bounds | Count |
|---|---|---|
soc[t] | [min_soc_mwh, max_soc_mwh] | T + 1 |
Typical Problem Size
| Configuration | Variables | Constraints | Solve 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.25hours (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] / √RTERTE 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 × ΔtWhere 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 × ΔtCapacity Allocation
afrr_up[t] ≤ P_batt × max_afrr_allocation
afrr_down[t] ≤ P_batt × max_afrr_allocation
fcr_capacity[t] ≤ P_batt × max_fcr_allocationBlock 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.25Imbalance Signal Constraints
if regulation_state[t] ∈ {0, 2}: imb_charge[t] = imb_discharge[t] = 0Daily Cycle Limit (optional)
Σ_{t∈day} (total_charge[t] + total_discharge[t]) ≤ max_cycles_per_day × 2 × C_battRamp Rate (optional)
|net_power[t] − net_power[t−1]| ≤ ramp_limitLinearized as two one-sided constraints.
Solver Stack
Per-window overhead:
| Phase | Time |
|---|---|
| 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.0clips prices below the 5th and above the 95th percentile
Capacity markets (aFRR, FCR) — multiplier scaling:
- Scales all prices by a factor
- Example:
0.8reduces 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 combinedmax_afrr_allocation: 0.3→ at most 30% for aFRR specificallymax_fcr_allocation: 0.2→ at most 20% for FCR specifically
Remaining power is available for day-ahead, intraday, and imbalance trading.