Skip to content

Repository files navigation

Battery Dispatch Optimizer

Dashboard Screenshot

This project computes optimal battery charge/discharge schedules for energy arbitrage using linear programming (PuLP), a FastAPI backend, and a React + Tailwind CSS frontend. It fetches live spot price data from AEMO (Australian Energy Market Operator) via the Open Electricity API.

System Flow (End-to-End)

Diagram

sequenceDiagram
    participant UI as React UI
        participant API as FastAPI
    participant OE as Open Electricity API
        participant SOLVER as Optimizer
            
            UI->>API: POST /simulate
    API->>OE: fetch_market_data
    OE->>API: spot prices
    API->>API: resample 30min
    API->>SOLVER: run solver
    SOLVER->>API: schedule
    API->>UI: JSON response
    UI->>UI: render chart
Loading

Diagram Explanation

  1. You configure Battery Capacity and Max Power Output in the UI.
  2. You click Run Optimization in the React app.
  3. The frontend sends a POST request to the FastAPI server at /simulate.
  4. The FastAPI server calls run_live_simulation() in main.py.
  5. The backend fetches live market data from AEMO via the Open Electricity API.
  6. The data is resampled to 30-minute intervals and passed to the optimization engine.
  7. The PuLP solver in calculate_optimal_dispatch() computes the optimal schedule.
  8. The backend returns the schedule, total profit, and status to the frontend.
  9. The React app renders the dispatch schedule chart with State of Charge and Spot Price.

Key Concepts

  • Grid: the electricity network. The battery can pull power from it or push power into it.
  • Battery / Energy Storage System: a device that stores electricity. It does not generate electricity. A solar panel generates electricity; a battery only stores it.
  • Charge: pulling electricity from the grid into the battery.
  • Discharge: pushing electricity from the battery into the grid.
  • State of Charge (SoC): the amount of energy currently stored in the battery, measured in MWh.
  • Spot price: the wholesale market price of electricity at a specific time, measured in AUD/MWh. In this project prices come from AEMO (Australian Energy Market Operator) via the Open Electricity API.
  • MW (megawatt): a unit of power — the rate at which electricity moves.
  • MWh (megawatt-hour): a unit of energy — the total amount of electricity moved. MWh = MW × hours.

Core Algorithms

1) Linear Programming Formulation (PuLP)

We formulate battery dispatch as a linear programming problem where the objective is to maximize arbitrage profit over a 24-hour horizon ($48 \times 30$-minute intervals). Intervals are indexed $t = 0, 1, \dots, T-1$ to match the Python code.

Parameters (given):

Symbol Meaning Unit
$C$ battery capacity MWh
$P$ max charge/discharge power MW
$\eta$ charging efficiency unitless
$\Delta t$ length of one interval 0.5 hours
$T$ number of intervals 48

Decision variables (chosen by the solver):

Symbol Meaning Unit
$c(t)$ charging power at interval $t$ MW
$d(t)$ discharging power at interval $t$ MW
$s(t)$ state of charge at interval $t$ MWh

Market data (fetched from AEMO):

Symbol Meaning Unit
$p(t)$ spot price at interval $t$ AUD/MWh

Objective function:

Maximize total profit $\Pi$ over the 24-hour horizon:

$$ \max \Pi = \sum_{t=0}^{T-1} \big( d(t) - c(t) \big) \cdot p(t) \cdot \Delta t $$

Each term $\big( d(t) - c(t) \big) \cdot \Delta t$ is the net energy sold (in MWh) during interval $t$. Multiplying by $p(t)$ gives the profit or cost for that interval in AUD.

Constraints:

  1. Power limits: $0 \leq c(t) \leq P$, $0 \leq d(t) \leq P$ for all $t = 0, \dots, T-1$
  2. Capacity limits: $0 \leq s(t) \leq C$ for all $t = 0, \dots, T-1$
  3. Energy balance:
    • First interval (battery starts empty):
      $$s(0) = \big( \eta \cdot c(0) - d(0) \big) \cdot \Delta t$$
    • Subsequent intervals:
      $$s(t) = s(t-1) + \big( \eta \cdot c(t) - d(t) \big) \cdot \Delta t \quad \text{for } t = 1, \dots, T-1$$

The efficiency $\eta$ means only a fraction of purchased energy actually enters storage; the rest is lost.

Implementation: main.py - calculate_optimal_dispatch()

2) Market Data Processing

Live spot prices are fetched from AEMO via the Open Electricity API:

  1. Query 5-minute price data for the last 24 hours
  2. Resample to 30-minute intervals using mean aggregation
  3. Take the most recent 48 periods (24 hours)
  4. Format as MarketInterval objects for the optimizer

Implementation: main.py - run_live_simulation()

Day-Ahead Forecasting Backtest

The live optimizer schedules against prices that have already happened (perfect foresight). The forecasting/ package measures how much of that revenue a real operator could capture by scheduling on forecast prices instead.

  • Data: SA1 prices from AEMO NEMWeb from 2019 onwards, averaged to 30 minutes in AEST and cached in data/sa1_prices.parquet.
  • Models: a naive baseline (same half-hour yesterday), LightGBM (lagged prices and calendar features) and a PyTorch LSTM (the past 7 days of prices plus calendar features). Each model forecasts all 48 prices of day D using only data up to the end of D-1. A pytest test checks for leakage.
  • Evaluation: walk-forward. Hyperparameters are chosen on a 3-month validation window. The final 12 months are the test period, with models retrained every 30 days. Each day, the unchanged LP is run on the forecast, and the resulting schedule is scored at actual prices.

Results

The test period ran from 12 Oct 2025 to 11 Oct 2026 (365 days). The battery was 5 MWh / 2.5 MW / 90% efficiency. With perfect foresight it would have earned $560,314.

Model Capture rate Realised revenue (AUD) MAE (AUD/MWh) RMSE (AUD/MWh) Days with a loss
Naive (yesterday) 27.2% 152,353 80.0 584.1 85
LightGBM 49.5% 277,312 57.8 417.2 32
LSTM 61.8% 346,394 51.2 413.9 28

Capture rate = total realised revenue ÷ total perfect-foresight revenue over all test days.

  • The LSTM won. It captured 62% of perfect-foresight revenue, against 49% for LightGBM and 27% for the naive baseline. It also had the lowest forecast error. It won every full quarter of the test year (49–68%) and only tied LightGBM in the 11-day partial quarter at the end.
  • Both ML models more than doubled the naive baseline's revenue and had about a third as many losing days (28–32 vs 85).
  • Revenue depends on timing, not average accuracy. South Australian revenue is dominated by a few extreme price spikes, so capture rates vary a lot from quarter to quarter. Getting the evening peak right matters far more than being accurate overnight.

When forecasts go wrong

Backtest page on the worst test day

The Backtest page on the worst test day, 18 May 2026:

  • What happened: prices were unusually high from morning to early afternoon (averaging $214/MWh from 06:00 to 14:00), then fell to about $105/MWh over the evening.
  • What the models expected: all three forecasts assumed a normal day, with a cheaper middle of the day and the usual 17:30 evening peak.
  • Result: every model charged during the expensive daytime and discharged into the cheap evening.
Perfect foresight Naive LightGBM LSTM
Profit on 18 May 2026 +$391 −$649 −$490 −$360

The LSTM lost the least, but it still lost money. A schedule fixed at the end of the previous day can't react when the day's pattern flips. The fix would be intraday re-optimisation using AEMO pre-dispatch forecasts (see results/REPORT.md for the full limitations).

To reproduce (results go to results/, see results/REPORT.md):

uv sync --extra forecast
uv run python -m forecasting.data       # optional: refresh the AEMO price cache
uv run python -m forecasting.backtest   # validation -> freeze -> walk-forward test -> report

The API serves the results at GET /api/v1/backtest/days and GET /api/v1/backtest/day/{date}. The React app shows them on the Backtest page (#/backtest).

Current Limitations

Single Market Region

Currently only supports SA1 (South Australia) region. The optimization does not account for:

  • Multiple region arbitrage
  • Network constraints or transmission losses
  • FCAS (Frequency Control Ancillary Services) revenue

Project Files (Key Pieces)

Deployment

  • Backend: Deployed on [Render] [1]
  • Frontend: Deployed on [S3 + CloudFront] [2]

CI/CD pipelines were created for both frontend and backend deployments via GitHub Actions.

References

  1. CloudFront - https://d2zg9d8ixwrq14.cloudfront.net
  2. FastAPI tutorial in VSCode.
  3. Preventing agents from reading .env or other confidential files
  4. Open Electricity Platform
  5. The Superpower Institute webpage
  6. Open Electricity API
  7. PuLP Documentation - Optimization with PuLP

Releases

Packages

Contributors

Languages