Executed battery-dispatch run over the full 2018-2020 Attachment 2 series (225.9s, 1096 rolling-horizon windows) and committed the results: outputs/ (dispatch.csv, annual/monthly summaries, summary.json, four figures), README.md (setup, run instructions, assumptions, the one-paragraph approach summary) and RESULTS.md (headline numbers, per-year table, charts, commentary). The full run surfaced three real issues, all fixed rather than papered over: - validate_schedule's soc_within_bounds check used a fixed 1e-6 MWh tolerance for the "state of charge below zero" case, while its sibling soc_matches_power_flows check already scales its tolerance with sqrt(n) for the same reason (CBC's own solver precision accumulating over a long cumulative sum). Over 52,608 half-hours this false-failed on a 5.1e-6 MWh solver-noise dip, not a real violation. Scaled it the same way. - cli.py's summary printed net_of_opex_gbp under the label "Less fixed opex", so the terminal output showed the post-opex profit (£193k) where a reader would expect the opex figure itself (£15k). Split into two correctly-labelled lines. - plots.py's monthly revenue chart stacked Market 1 (always negative here) and Market 2 (always positive) with a single running `bottom`, which is only correct for same-signed series: Market 2's bar completely overlapped and painted over Market 1's, hiding the -£297k Market 1 loss entirely. Now accumulates positive and negative contributions on separate baselines and marks the net. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.7 KiB
Results — three-year dispatch, 2018-01-01 to 2020-12-31
Reproduced by:
uv run battery-dispatch run --start 2018-01-01 --end 2020-12-31 --out outputs/
Wall-clock: 225.9s (1,096 rolling-horizon windows, all 1,096 needing the MILP fallback).
Independent validation: all 7 checks passed (power limits, no simultaneous charge/discharge,
Market 2's hourly commitment held across all 26,304 hours, state of charge and revenue both
recomputed from scratch and matched the reported schedule). Every file below is in outputs/,
generated by this exact command.
Headline
| Metric | Value |
|---|---|
| Gross trading profit | £208,430.85 over 3 years (£69,461/yr) |
| Fixed opex | £15,003.42 over 3 years (£5,000/yr) |
| Net of opex | £193,427.43 |
| Energy discharged to grid | 13,178.5 MWh (14,602.2 MWh charged) |
| Average captured spread | £15.82/MWh discharged |
| Equivalent full cycles | 3,468.0 (1,155.7/yr — 69.4% of the 5,000-cycle budget) |
| Capacity remaining | 3.8613 MWh (96.53% of nominal) |
| Implied life | 4.3 years (cycle-budget-limited, not the 10-year calendar life) |
The revenue split is the interesting number
| Market 1 (half-hourly) | Market 2 (hourly) | |
|---|---|---|
| 3-year profit | -£297,418.06 | +£505,848.91 |
| Share of gross profit | -142.7% | +242.7% |
Market 1 runs at a large loss and Market 2 more than covers it. This is not a bug — it reflects how
the optimiser actually uses the two markets, and is checked directly in monthly_revenue.png
below: Market 1 is used mainly as the cheap-import channel, Market 2 as the sell channel.
Market 1 trades on a finer (half-hourly) grid, so it captures the cheapest individual half-hours to
charge, while Market 2's coarser hourly commitment makes it the more reliably profitable side to
sell into across an hour. Gross trading profit (the number that matters) is the sum of the two,
and it is positive and consistent in every one of the 36 months in the run.
Per-year
| Year | Gross profit | Market 1 | Market 2 | Discharged (MWh) | Cycles | Captured spread (£/MWh) |
|---|---|---|---|---|---|---|
| 2018 | £67,799.45 | -£115,113.36 | £182,912.82 | 3,696.8 | 972.8 | £18.34 |
| 2019 | £64,299.71 | -£103,884.58 | £168,184.30 | 4,459.4 | 1,173.5 | £14.42 |
| 2020 | £76,331.69 | -£78,420.11 | £154,751.80 | 5,022.2 | 1,321.6 | £15.20 |
Cycling intensifies year over year (972.8 → 1,321.6 cycles/yr) as the model exploits more of the available spread, while the captured spread per MWh discharged drifts down slightly — consistent with cycling harder into thinner margins as capacity fades a little each year.
A representative week
The median-revenue week of the run (13–20 April 2020), showing both markets' prices, the battery's dispatch by market, and state of charge:
The battery cycles multiple times most days, buying the overnight trough and selling into each day's peak(s) across both markets, exactly as intended.
Cycling and capacity fade
3,468 equivalent full cycles against Attachment 1's 5,000-cycle budget, fading capacity from 4.0 to 3.8613 MWh (96.53% retained) over the three years. At this cycling rate the battery would exhaust its cycle budget — the binding end-of-life constraint, not the 10-year calendar life — in about 4.3 years.
Captured spread distribution
A tight, right-skewed distribution centred on a median of £14.6/MWh discharged, with a handful of much larger days (negative-price events and unusually wide spreads) pulling the mean above the median.
Solver behaviour
The MILP fallback (binary charge/discharge exclusivity) fired in every one of the 1,096
windows — the persistent gap between Market 1 and Market 2 prices makes the LP relaxation's
"charge and discharge at once" cheat attractive almost daily, exactly as anticipated in
optimiser.py's docstring. Despite that, the full three-year run completed in 225.9s (~3.8
minutes), well inside the 10-minute target: on real price data each 48-hour MILP window (96
binaries) solves in roughly 0.1–0.2s, because real prices vary enough half-hour to half-hour that
the LP relaxation is already close to integral. (A test fixture using long runs of exactly-repeated
synthetic prices hit pathological CBC branch-and-bound behaviour during development — tens of
thousands of nodes without closing the optimality gap — which is what real Attachment 2 data never
triggers; see the test suite for the fix.)



