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.
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
- You configure Battery Capacity and Max Power Output in the UI.
- You click Run Optimization in the React app.
- The frontend sends a POST request to the FastAPI server at
/simulate. - The FastAPI server calls
run_live_simulation()in main.py. - The backend fetches live market data from AEMO via the Open Electricity API.
- The data is resampled to 30-minute intervals and passed to the optimization engine.
- The PuLP solver in
calculate_optimal_dispatch()computes the optimal schedule. - The backend returns the schedule, total profit, and status to the frontend.
- The React app renders the dispatch schedule chart with State of Charge and Spot Price.
- 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.
We formulate battery dispatch as a linear programming problem where the objective is to maximize arbitrage profit over a 24-hour horizon (
Parameters (given):
| Symbol | Meaning | Unit |
|---|---|---|
| battery capacity | MWh | |
| max charge/discharge power | MW | |
| charging efficiency | unitless | |
| length of one interval | 0.5 hours | |
| number of intervals | 48 |
Decision variables (chosen by the solver):
| Symbol | Meaning | Unit |
|---|---|---|
| charging power at interval |
MW | |
| discharging power at interval |
MW | |
| state of charge at interval |
MWh |
Market data (fetched from AEMO):
| Symbol | Meaning | Unit |
|---|---|---|
| spot price at interval |
AUD/MWh |
Objective function:
Maximize total profit
Each term
Constraints:
-
Power limits:
$0 \leq c(t) \leq P$ ,$0 \leq d(t) \leq P$ for all$t = 0, \dots, T-1$ -
Capacity limits:
$0 \leq s(t) \leq C$ for all$t = 0, \dots, T-1$ -
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$$
- First interval (battery starts empty):
The efficiency
Implementation: main.py - calculate_optimal_dispatch()
Live spot prices are fetched from AEMO via the Open Electricity API:
- Query 5-minute price data for the last 24 hours
- Resample to 30-minute intervals using mean aggregation
- Take the most recent 48 periods (24 hours)
- Format as
MarketIntervalobjects for the optimizer
Implementation: main.py - run_live_simulation()
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.
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.
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 -> reportThe 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).
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
- FastAPI backend: main.py
- Optimization engine: optimizer.py
- Data pipeline: pipeline.py
- React frontend: frontend/src/App.tsx
- Frontend styles: frontend/src/index.css
- Data generator: generate_data.py
- 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.
- CloudFront - https://d2zg9d8ixwrq14.cloudfront.net
- FastAPI tutorial in VSCode.
- Preventing agents from reading .env or other confidential files
- Open Electricity Platform
- The Superpower Institute webpage
- Open Electricity API
- PuLP Documentation - Optimization with PuLP

