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>
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
||||
# Results — three-year dispatch, 2018-01-01 to 2020-12-31
|
||||
|
||||
Reproduced by:
|
||||
|
||||
```bash
|
||||
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.)
|
||||
Reference in New Issue
Block a user